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.
This commit is contained in:
iliya 2026-03-11 13:28:44 +02:00
parent c5c41d2a0d
commit b6ec408451
29 changed files with 2255 additions and 829 deletions

View file

@ -1,7 +1,14 @@
import React, { Component, type ErrorInfo, type ReactNode } from 'react'; import React, { Component, type ErrorInfo, type ReactNode } from 'react';
import { useStore } from '@renderer/store';
import { createLogger } from '@shared/utils/logger'; 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'); const logger = createLogger('Component:ErrorBoundary');
@ -12,15 +19,19 @@ interface Props {
interface State { interface State {
hasError: boolean; hasError: boolean;
copiedReport: boolean;
error: Error | null; error: Error | null;
errorInfo: ErrorInfo | null; errorInfo: ErrorInfo | null;
} }
export class ErrorBoundary extends Component<Props, State> { export class ErrorBoundary extends Component<Props, State> {
private copyResetTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
hasError: false, hasError: false,
copiedReport: false,
error: null, error: null,
errorInfo: null, errorInfo: null,
}; };
@ -40,16 +51,83 @@ export class ErrorBoundary extends Component<Props, State> {
}; };
handleReset = (): void => { handleReset = (): void => {
if (this.copyResetTimeout) {
clearTimeout(this.copyResetTimeout);
this.copyResetTimeout = null;
}
this.setState({ this.setState({
hasError: false, hasError: false,
copiedReport: false,
error: null, error: null,
errorInfo: 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<void> => {
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 // eslint-disable-next-line sonarjs/function-return-type -- Error boundaries inherently return different content based on error state
render(): ReactNode { render(): ReactNode {
const { hasError, error, errorInfo } = this.state; const { hasError, copiedReport, error, errorInfo } = this.state;
const { children, fallback } = this.props; const { children, fallback } = this.props;
if (hasError) { if (hasError) {
@ -85,13 +163,31 @@ export class ErrorBoundary extends Component<Props, State> {
</div> </div>
)} )}
<div className="flex gap-4"> <div className="flex flex-wrap justify-center gap-4">
<button <button
onClick={this.handleReset} onClick={this.handleReset}
className="flex items-center gap-2 rounded-lg border border-claude-dark-border bg-claude-dark-surface px-4 py-2 transition-colors hover:bg-claude-dark-border" className="flex items-center gap-2 rounded-lg border border-claude-dark-border bg-claude-dark-surface px-4 py-2 transition-colors hover:bg-claude-dark-border"
> >
Try Again Try Again
</button> </button>
<button
onClick={() => void this.handleCopyErrorDetails()}
className="flex items-center gap-2 rounded-lg border border-claude-dark-border bg-claude-dark-surface px-4 py-2 transition-colors hover:bg-claude-dark-border"
>
{copiedReport ? (
<Check className="size-4 text-green-400" />
) : (
<Copy className="size-4" />
)}
{copiedReport ? 'Copied' : 'Copy Error Details'}
</button>
<button
onClick={this.handleCreateGitHubIssue}
className="flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2 text-red-300 transition-colors hover:bg-red-500/20"
>
<Bug className="size-4" />
Report Bug on GitHub
</button>
<button <button
onClick={this.handleReload} onClick={this.handleReload}
className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 transition-colors hover:bg-blue-700" className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 transition-colors hover:bg-blue-700"
@ -100,6 +196,10 @@ export class ErrorBoundary extends Component<Props, State> {
Reload App Reload App
</button> </button>
</div> </div>
<p className="mt-4 max-w-md text-center text-xs text-claude-dark-text-secondary">
GitHub bug reports and copied diagnostics include the error message, stack traces, app
version, active tab, selected team, task context, and environment details.
</p>
</div> </div>
); );
} }

View file

@ -50,6 +50,8 @@ function getStatusLabel(column: string): string {
interface TaskTooltipProps { interface TaskTooltipProps {
/** Canonical task id or short display id reference. */ /** Canonical task id or short display id reference. */
taskId: string; taskId: string;
/** Optional owning team for cross-team task references. */
teamName?: string;
/** Rendered trigger element. */ /** Rendered trigger element. */
children: React.ReactElement; children: React.ReactElement;
/** Tooltip placement. */ /** Tooltip placement. */
@ -62,11 +64,34 @@ interface TaskTooltipProps {
*/ */
export const TaskTooltip = ({ export const TaskTooltip = ({
taskId, taskId,
teamName,
children, children,
side = 'top', side = 'top',
}: TaskTooltipProps): React.JSX.Element => { }: TaskTooltipProps): React.JSX.Element => {
const tasks = useStore((s) => s.selectedTeamData?.tasks); const selectedTeamName = useStore((s) => s.selectedTeamName);
const members = useStore((s) => s.selectedTeamData?.members); 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]); const task = useMemo(() => tasks?.find((t) => taskMatchesRef(t, taskId)), [tasks, taskId]);
@ -81,11 +106,24 @@ export const TaskTooltip = ({
const column = getEffectiveColumn(task); const column = getEffectiveColumn(task);
const statusColor = STATUS_COLORS[column] ?? STATUS_COLORS.pending; const statusColor = STATUS_COLORS[column] ?? STATUS_COLORS.pending;
const label = getStatusLabel(column); 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 ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger> <TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side={side} className="max-w-xs space-y-1.5 p-2.5"> <TooltipContent side={side} className="max-w-xs space-y-1.5 p-2.5">
{resolvedTeamName ? (
<div className="text-[10px] uppercase tracking-wide text-[var(--color-text-muted)]">
{resolvedTeamDisplayName || resolvedTeamName}
</div>
) : null}
{/* Subject */} {/* Subject */}
<div className="text-xs font-medium text-[var(--color-text)]"> <div className="text-xs font-medium text-[var(--color-text)]">
<span className="text-[var(--color-text-muted)]">{formatTaskDisplayLabel(task)}</span>{' '} <span className="text-[var(--color-text-muted)]">{formatTaskDisplayLabel(task)}</span>{' '}
@ -109,8 +147,10 @@ export const TaskTooltip = ({
) : null} ) : null}
{/* Owner */} {/* Owner */}
{task.owner ? ( {task.owner && members.length > 0 ? (
<MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> <MemberBadge name={task.owner} color={colorMap.get(task.owner)} />
) : task.owner ? (
<span className="text-[10px] text-[var(--color-text-secondary)]">{task.owner}</span>
) : ( ) : (
<span className="text-[10px] text-[var(--color-text-muted)]">Unassigned</span> <span className="text-[10px] text-[var(--color-text-muted)]">Unassigned</span>
)} )}

View file

@ -15,37 +15,27 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useBranchSync } from '@renderer/hooks/useBranchSync'; import { useBranchSync } from '@renderer/hooks/useBranchSync';
import { useResizablePanel } from '@renderer/hooks/useResizablePanel';
import { useTabUI } from '@renderer/hooks/useTabUI'; 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 { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { createChipFromSelection } from '@renderer/utils/chipUtils'; import { createChipFromSelection } from '@renderer/utils/chipUtils';
import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath'; import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies';
import { formatProjectPath } from '@renderer/utils/pathDisplay'; import { formatProjectPath } from '@renderer/utils/pathDisplay';
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize'; import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup'; 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 { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { import {
AlertTriangle, AlertTriangle,
Bell,
CheckCheck,
ChevronsDownUp,
ChevronsUpDown,
ChevronRight,
Clock, Clock,
Code, Code,
Columns3, Columns3,
FolderOpen, FolderOpen,
GitBranch, GitBranch,
History, History,
MessageSquare,
Pencil, Pencil,
Play, Play,
Plus, Plus,
@ -59,9 +49,6 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; 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 { AddMemberDialog } from './dialogs/AddMemberDialog';
import { CreateTaskDialog } from './dialogs/CreateTaskDialog'; import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
import { EditTeamDialog } from './dialogs/EditTeamDialog'; import { EditTeamDialog } from './dialogs/EditTeamDialog';
@ -78,8 +65,7 @@ const ProjectEditorOverlay = lazy(() =>
import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay }))
); );
import { MemberList } from './members/MemberList'; import { MemberList } from './members/MemberList';
import { MessageComposer } from './messages/MessageComposer'; import { MessagesPanel } from './messages/MessagesPanel';
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
import { ChangeReviewDialog } from './review/ChangeReviewDialog'; import { ChangeReviewDialog } from './review/ChangeReviewDialog';
import { ClaudeLogsSection } from './ClaudeLogsSection'; import { ClaudeLogsSection } from './ClaudeLogsSection';
import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection';
@ -90,16 +76,10 @@ import { TeamSessionsSection } from './TeamSessionsSection';
import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
import type { KanbanSortState } from './kanban/KanbanSortPopover'; import type { KanbanSortState } from './kanban/KanbanSortPopover';
import type { MessagesFilterState } from './messages/MessagesFilterPopover';
import type { ContextInjection } from '@renderer/types/contextInjection'; import type { ContextInjection } from '@renderer/types/contextInjection';
import type { Session } from '@renderer/types/data'; import type { Session } from '@renderer/types/data';
import type { InlineChip } from '@renderer/types/inlineChip'; import type { InlineChip } from '@renderer/types/inlineChip';
import type { import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
InboxMessage,
MemberSpawnStatusEntry,
ResolvedTeamMember,
TeamTaskWithKanban,
} from '@shared/types';
import type { EditorSelectionAction } from '@shared/types/editor'; import type { EditorSelectionAction } from '@shared/types/editor';
interface TeamDetailViewProps { interface TeamDetailViewProps {
@ -223,7 +203,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
updateTaskStatus, updateTaskStatus,
updateTaskOwner, updateTaskOwner,
sendTeamMessage, sendTeamMessage,
sendCrossTeamMessage,
requestReview, requestReview,
createTeamTask, createTeamTask,
startTask, startTask,
@ -252,6 +231,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
fetchDeletedTasks, fetchDeletedTasks,
deletedTasks, deletedTasks,
launchParams, launchParams,
messagesPanelMode,
messagesPanelWidth,
setMessagesPanelMode,
setMessagesPanelWidth,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
data: s.selectedTeamData, data: s.selectedTeamData,
@ -268,7 +251,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
updateTaskStatus: s.updateTaskStatus, updateTaskStatus: s.updateTaskStatus,
updateTaskOwner: s.updateTaskOwner, updateTaskOwner: s.updateTaskOwner,
sendTeamMessage: s.sendTeamMessage, sendTeamMessage: s.sendTeamMessage,
sendCrossTeamMessage: s.sendCrossTeamMessage,
requestReview: s.requestReview, requestReview: s.requestReview,
createTeamTask: s.createTeamTask, createTeamTask: s.createTeamTask,
startTask: s.startTask, startTask: s.startTask,
@ -299,6 +281,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
fetchDeletedTasks: s.fetchDeletedTasks, fetchDeletedTasks: s.fetchDeletedTasks,
deletedTasks: s.deletedTasks, deletedTasks: s.deletedTasks,
launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined, 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(); } = useTabUI();
const [isContextButtonHovered, setIsContextButtonHovered] = useState(false); 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(() => { useEffect(() => {
if (tabId) { if (tabId) {
initTabUIState(tabId); initTabUIState(tabId);
@ -344,15 +344,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}, [memberSpawnStatuses]); }, [memberSpawnStatuses]);
const [kanbanSearch, setKanbanSearch] = useState(''); const [kanbanSearch, setKanbanSearch] = useState('');
const [messagesSearchQuery, setMessagesSearchQuery] = useState('');
const [messagesFilter, setMessagesFilter] = useState<MessagesFilterState>({
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) // Open editor overlay when a file reveal is requested (e.g. from chip click)
const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); const pendingRevealFile = useStore((s) => s.editorPendingRevealFile);
@ -633,32 +624,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
[data?.members] [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 kanbanDisplayTasks = useMemo(() => {
const query = kanbanSearch.trim(); const query = kanbanSearch.trim();
if (!query) return filteredTasks; 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 taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [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 = ( const openCreateTaskDialog = (
subject = '', subject = '',
@ -1007,6 +928,53 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</div> </div>
)} )}
{/* Messages sidebar (left, after context panel) */}
{messagesPanelMode === 'sidebar' && (
<div
className="relative shrink-0 overflow-hidden border-r border-[var(--color-border)]"
style={{ width: messagesPanelWidth }}
>
<MessagesPanel
teamName={teamName}
position="sidebar"
onTogglePosition={toggleMessagesPanelMode}
members={activeMembers}
tasks={data.tasks}
messages={data.messages}
isTeamAlive={data.isAlive}
timeWindow={timeWindow}
teamSessionIds={teamSessionIds}
currentLeadSessionId={data?.config.leadSessionId}
pendingRepliesByMember={pendingRepliesByMember}
onPendingReplyChange={setPendingRepliesByMember}
onMemberClick={setSelectedMember}
onTaskClick={setSelectedTask}
onCreateTaskFromMessage={(subject, description) => {
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 */}
<div
className={`absolute inset-y-0 right-0 z-20 w-1 cursor-col-resize transition-colors hover:bg-blue-500/30 ${isMessagesPanelResizing ? 'bg-blue-500/40' : ''}`}
onMouseDown={messagesPanelHandleProps.onMouseDown}
/>
</div>
)}
<div <div
ref={contentRef} ref={contentRef}
className="relative size-full flex-1 overflow-auto p-4" className="relative size-full flex-1 overflow-auto p-4"
@ -1557,194 +1525,22 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
<ClaudeLogsSection teamName={teamName} /> <ClaudeLogsSection teamName={teamName} />
<CollapsibleTeamSection {messagesPanelMode === 'inline' && (
sectionId="messages" <MessagesPanel
title="Messages"
icon={<MessageSquare size={14} />}
badge={filteredMessages.length}
secondaryBadge={
filteredMessages.length > 0 && messagesUnreadCount > 0
? messagesUnreadCount
: undefined
}
afterBadge={
messagesUnreadCount > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="pointer-events-auto flex items-center gap-1 rounded-md px-1.5 py-1 text-[11px] text-blue-400 transition-colors hover:bg-blue-500/10"
onClick={(e) => {
e.stopPropagation();
handleMarkAllRead();
}}
>
<CheckCheck size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Mark all as read</TooltipContent>
</Tooltip>
) : undefined
}
headerExtra={
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
void window.electronAPI.openExternal(
'https://github.com/777genius/claude-notifications-go'
);
}}
>
<Bell size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Desktop notifications plugin</TooltipContent>
</Tooltip>
}
defaultOpen
action={
<div className="flex items-center gap-2 pl-2 pr-2">
<div className="flex w-36 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
<input
type="text"
placeholder="Search..."
value={messagesSearchQuery}
onChange={(e) => 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 && (
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => setMessagesSearchQuery('')}
>
<X size={14} />
</button>
)}
</div>
<MessagesFilterPopover
filter={messagesFilter}
messages={data?.messages ?? []}
open={messagesFilterOpen}
onOpenChange={setMessagesFilterOpen}
onApply={setMessagesFilter}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="pointer-events-auto size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
setMessagesCollapsed((v) => !v);
}}
>
{messagesCollapsed ? (
<ChevronsUpDown size={14} />
) : (
<ChevronsDownUp size={14} />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
</TooltipContent>
</Tooltip>
</div>
}
>
<MessageComposer
teamName={teamName} teamName={teamName}
position="inline"
onTogglePosition={toggleMessagesPanelMode}
members={activeMembers} members={activeMembers}
tasks={data.tasks}
messages={data.messages}
isTeamAlive={data.isAlive} isTeamAlive={data.isAlive}
sending={sendingMessage} timeWindow={timeWindow}
sendError={sendMessageError}
lastResult={lastSendMessageResult}
onSend={(member, text, summary, attachments, actionMode) => {
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 && (
<>
<div className="relative h-0">
<button
type="button"
className="absolute -top-[19px] right-0 z-10 flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={() => setStatusBlockCollapsed((prev) => !prev)}
aria-label={statusBlockCollapsed ? 'Expand status' : 'Collapse status'}
>
<ChevronRight
size={12}
className={`shrink-0 transition-transform duration-150 ${statusBlockCollapsed ? '' : 'rotate-90'}`}
/>
Status
</button>
</div>
{!statusBlockCollapsed && (
<div className="mt-5">
<PendingRepliesBlock
members={data.members}
pendingRepliesByMember={pendingRepliesByMember}
pendingCrossTeamReplies={pendingCrossTeamReplies}
onMemberClick={setSelectedMember}
/>
<ActiveTasksBlock
members={data.members}
tasks={data.tasks}
onMemberClick={setSelectedMember}
onTaskClick={setSelectedTask}
/>
</div>
)}
</>
)}
<ActivityTimeline
messages={filteredMessages}
teamName={teamName}
members={data.members}
readState={{ readSet, getMessageKey: toMessageKey }}
allCollapsed={messagesCollapsed}
expandOverrides={expandedSet}
onToggleExpandOverride={toggleExpandOverride}
teamSessionIds={teamSessionIds} teamSessionIds={teamSessionIds}
currentLeadSessionId={data?.config.leadSessionId} currentLeadSessionId={data?.config.leadSessionId}
pendingRepliesByMember={pendingRepliesByMember}
onPendingReplyChange={setPendingRepliesByMember}
onMemberClick={setSelectedMember} onMemberClick={setSelectedMember}
onTaskClick={setSelectedTask}
onCreateTaskFromMessage={(subject, description) => { onCreateTaskFromMessage={(subject, description) => {
openCreateTaskDialog(subject, description); openCreateTaskDialog(subject, description);
}} }}
@ -1755,7 +1551,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) });
setSendDialogOpen(true); setSendDialogOpen(true);
}} }}
onMessageVisible={handleMessageVisible}
onRestartTeam={() => setLaunchDialogOpen(true)} onRestartTeam={() => setLaunchDialogOpen(true)}
onTaskIdClick={(taskId) => { onTaskIdClick={(taskId) => {
const task = const task =
@ -1764,7 +1559,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
if (task) setSelectedTask(task); if (task) setSelectedTask(task);
}} }}
/> />
</CollapsibleTeamSection> )}
<ReviewDialog <ReviewDialog
open={requestChangesTaskId !== null} open={requestChangesTaskId !== null}

View file

@ -24,6 +24,7 @@ import {
} from '@renderer/utils/agentMessageFormatting'; } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { import {
CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SENT_SOURCE,
@ -257,11 +258,6 @@ const AUTH_ERROR_PATTERNS = [
// Full message card — left colored border, name badge, collapsible content // Full message card — left colored border, name badge, collapsible content
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Convert `#<task-display-id>` 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 `#<task-display-id>` in plain text as clickable inline elements with TaskTooltip. */ /** Render `#<task-display-id>` in plain text as clickable inline elements with TaskTooltip. */
function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] { function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
return text.split(/(#[A-Za-z0-9-]+\b)/g).map((part, i) => { return text.split(/(#[A-Za-z0-9-]+\b)/g).map((part, i) => {
@ -304,7 +300,9 @@ export const ActivityItem = ({
}: ActivityItemProps): React.JSX.Element => { }: ActivityItemProps): React.JSX.Element => {
const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const colors = getTeamColorSet(memberColor ?? message.color ?? '');
const { isLight } = useTheme(); 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 teams = useStore((s) => s.teams);
const teamNames = useMemo( const teamNames = useMemo(
@ -312,9 +310,18 @@ export const ActivityItem = ({
[teams] [teams]
); );
const timestamp = Number.isNaN(Date.parse(message.timestamp)) const timestamp = useMemo(() => {
? message.timestamp if (Number.isNaN(Date.parse(message.timestamp))) return message.timestamp;
: new Date(message.timestamp).toLocaleString(); 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); const structured = parseStructuredAgentMessage(message.text);
// Only flag agent messages as rate-limited, not user's own quotes // Only flag agent messages as rate-limited, not user's own quotes

View file

@ -15,12 +15,11 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { ChevronDown, ChevronRight, ChevronUp, Reply } from 'lucide-react'; import { ChevronDown, ChevronRight, ChevronUp, Reply } from 'lucide-react';
import { linkifyTaskIdsInMarkdown } from './ActivityItem';
import { import {
AnimatedHeightReveal, AnimatedHeightReveal,
ENTRY_REVEAL_ANIMATION_MS, ENTRY_REVEAL_ANIMATION_MS,

View file

@ -24,6 +24,7 @@ import {
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
@ -78,6 +79,7 @@ export const CreateTaskDialog = ({
}: CreateTaskDialogProps): React.JSX.Element => { }: CreateTaskDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
const [subject, setSubject] = useState(defaultSubject); const [subject, setSubject] = useState(defaultSubject);
const descriptionDraft = useDraftPersistence({ const descriptionDraft = useDraftPersistence({
key: `createTask:${teamName}:description`, key: `createTask:${teamName}:description`,
@ -291,6 +293,7 @@ export const CreateTaskDialog = ({
value={descriptionDraft.value} value={descriptionDraft.value}
onValueChange={descriptionDraft.setValue} onValueChange={descriptionDraft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
chips={descChipDraft.chips} chips={descChipDraft.chips}
onChipRemove={handleDescChipRemove} onChipRemove={handleDescChipRemove}
projectPath={projectPath} projectPath={projectPath}
@ -315,6 +318,7 @@ export const CreateTaskDialog = ({
value={promptDraft.value} value={promptDraft.value}
onValueChange={promptDraft.setValue} onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
projectPath={projectPath} projectPath={projectPath}
minRows={3} minRows={3}
maxRows={12} maxRows={12}

View file

@ -34,6 +34,7 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { AdvancedCliSection } from './AdvancedCliSection'; import { AdvancedCliSection } from './AdvancedCliSection';
import { EffortLevelSelector } from './EffortLevelSelector'; import { EffortLevelSelector } from './EffortLevelSelector';
import { ExtendedContextCheckbox } from './ExtendedContextCheckbox'; import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
import { OptionalSettingsSection } from './OptionalSettingsSection';
import { ProjectPathSelector } from './ProjectPathSelector'; import { ProjectPathSelector } from './ProjectPathSelector';
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox'; import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
@ -561,6 +562,34 @@ export const CreateTeamDialog = ({
return args; return args;
}, [skipPermissions, effectiveModel, selectedEffort]); }, [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 activeError = localError ?? provisioningError;
const canOpenExistingTeam = const canOpenExistingTeam =
activeError?.includes('Team already exists') === true && request.teamName.length > 0; activeError?.includes('Team already exists') === true && request.teamName.length > 0;
@ -810,16 +839,21 @@ export const CreateTeamDialog = ({
/> />
</div> </div>
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-4 md:col-span-2"> <div className="rounded-lg border border-[var(--color-border-emphasis)] bg-[var(--color-surface-overlay)] p-4 shadow-sm md:col-span-2">
<div className="flex items-center gap-2"> <div className="flex items-start gap-3">
<Checkbox <Checkbox
id="launch-team" id="launch-team"
checked={launchTeam} checked={launchTeam}
onCheckedChange={(checked) => setLaunchTeam(checked === true)} onCheckedChange={(checked) => setLaunchTeam(checked === true)}
/> />
<Label htmlFor="launch-team" className="cursor-pointer"> <div className="space-y-1">
Launch team <Label htmlFor="launch-team" className="cursor-pointer text-sm font-medium">
</Label> Run command after create
</Label>
<p className="text-xs text-[var(--color-text-muted)]">
Start the team immediately via local Claude CLI.
</p>
</div>
</div> </div>
{launchTeam ? ( {launchTeam ? (
@ -837,119 +871,136 @@ export const CreateTeamDialog = ({
fieldError={fieldErrors.cwd} fieldError={fieldErrors.cwd}
/> />
<div className="space-y-1.5"> <OptionalSettingsSection
<Label htmlFor="team-prompt" className="label-optional"> title="Optional launch settings"
Prompt for team lead (optional) description="Prompt, model, safety, and CLI overrides live here when you need them."
</Label> summary={launchOptionalSummary}
<MentionableTextarea >
id="team-prompt" <div className="space-y-4">
className="text-xs" <div className="space-y-1.5">
minRows={3} <Label htmlFor="team-prompt" className="label-optional">
maxRows={12} Prompt for team lead (optional)
value={prompt} </Label>
onValueChange={promptDraft.setValue} <MentionableTextarea
suggestions={soloTeam ? [] : mentionSuggestions} id="team-prompt"
projectPath={effectiveCwd || null} className="text-xs"
chips={promptChipDraft.chips} minRows={3}
onChipRemove={promptChipDraft.removeChip} maxRows={12}
onFileChipInsert={promptChipDraft.addChip} value={prompt}
placeholder="Instructions for the team lead during provisioning..." onValueChange={promptDraft.setValue}
footerRight={ suggestions={soloTeam ? [] : mentionSuggestions}
promptDraft.isSaved ? ( projectPath={effectiveCwd || null}
<span className="text-[10px] text-[var(--color-text-muted)]"> chips={promptChipDraft.chips}
Draft saved onChipRemove={promptChipDraft.removeChip}
</span> onFileChipInsert={promptChipDraft.addChip}
) : null placeholder="Instructions for the team lead during provisioning..."
} footerRight={
/> promptDraft.isSaved ? (
</div> <span className="text-[10px] text-[var(--color-text-muted)]">
Draft saved
</span>
) : null
}
/>
</div>
<div> <div>
<TeamModelSelector <TeamModelSelector
value={selectedModel} value={selectedModel}
onValueChange={setSelectedModel} onValueChange={setSelectedModel}
id="create-model" id="create-model"
/> />
<EffortLevelSelector <EffortLevelSelector
value={selectedEffort} value={selectedEffort}
onValueChange={setSelectedEffort} onValueChange={setSelectedEffort}
id="create-effort" id="create-effort"
/> />
<ExtendedContextCheckbox <ExtendedContextCheckbox
id="create-extended-context" id="create-extended-context"
checked={extendedContext} checked={extendedContext}
onCheckedChange={setExtendedContext} onCheckedChange={setExtendedContext}
disabled={selectedModel === 'haiku'} disabled={selectedModel === 'haiku'}
/> />
{launchTeam && ( <SkipPermissionsCheckbox
<SkipPermissionsCheckbox id="create-skip-permissions"
id="create-skip-permissions" checked={skipPermissions}
checked={skipPermissions} onCheckedChange={setSkipPermissions}
onCheckedChange={setSkipPermissions} />
</div>
<AdvancedCliSection
teamName={advancedKey}
internalArgs={internalArgs}
worktreeEnabled={worktreeEnabled}
onWorktreeEnabledChange={setWorktreeEnabled}
worktreeName={worktreeName}
onWorktreeNameChange={setWorktreeName}
customArgs={customArgs}
onCustomArgsChange={setCustomArgs}
/> />
)} </div>
</div> </OptionalSettingsSection>
<AdvancedCliSection
teamName={advancedKey}
internalArgs={internalArgs}
worktreeEnabled={worktreeEnabled}
onWorktreeEnabledChange={setWorktreeEnabled}
worktreeName={worktreeName}
onWorktreeNameChange={setWorktreeName}
customArgs={customArgs}
onCustomArgsChange={setCustomArgs}
/>
</div> </div>
) : null} ) : null}
</div> </div>
<div className="space-y-1.5 md:col-span-2"> <div className="md:col-span-2">
<Label htmlFor="team-description" className="label-optional"> <OptionalSettingsSection
Description (optional) title="Optional team details"
</Label> description="Keep the default flow compact and only open this when you want extra context or a custom color."
<AutoResizeTextarea summary={teamDetailsSummary}
id="team-description" >
className="text-xs" <div className="space-y-4">
minRows={2} <div className="space-y-1.5">
maxRows={8} <Label htmlFor="team-description" className="label-optional">
value={description} Description (optional)
onChange={(event) => descriptionDraft.setValue(event.target.value)} </Label>
placeholder="Brief description of the team purpose" <AutoResizeTextarea
/> id="team-description"
{descriptionDraft.isSaved ? ( className="text-xs"
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> minRows={2}
) : null} maxRows={8}
</div> value={description}
onChange={(event) => descriptionDraft.setValue(event.target.value)}
placeholder="Brief description of the team purpose"
/>
{descriptionDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
<div className="space-y-1.5 md:col-span-2"> <div className="space-y-1.5">
<Label className="label-optional">Color (optional)</Label> <Label className="label-optional">Color (optional)</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{TEAM_COLOR_NAMES.map((colorName) => { {TEAM_COLOR_NAMES.map((colorName) => {
const colorSet = getTeamColorSet(colorName); const colorSet = getTeamColorSet(colorName);
const isSelected = teamColor === colorName; const isSelected = teamColor === colorName;
return ( return (
<button <button
key={colorName} key={colorName}
type="button" type="button"
className={cn( className={cn(
'flex size-7 items-center justify-center rounded-full border-2 transition-all', 'flex size-7 items-center justify-center rounded-full border-2 transition-all',
isSelected ? 'scale-110' : 'opacity-70 hover:opacity-100' isSelected ? 'scale-110' : 'opacity-70 hover:opacity-100'
)} )}
style={{ style={{
backgroundColor: getThemedBadge(colorSet, isLight), backgroundColor: getThemedBadge(colorSet, isLight),
borderColor: isSelected ? colorSet.border : 'transparent', borderColor: isSelected ? colorSet.border : 'transparent',
}} }}
title={colorName} title={colorName}
onClick={() => setTeamColor(isSelected ? '' : colorName)} onClick={() => setTeamColor(isSelected ? '' : colorName)}
> >
<span <span
className="size-3.5 rounded-full" className="size-3.5 rounded-full"
style={{ backgroundColor: colorSet.border }} style={{ backgroundColor: colorSet.border }}
/> />
</button> </button>
); );
})} })}
</div> </div>
</div>
</div>
</OptionalSettingsSection>
</div> </div>
</div> </div>

View file

@ -39,6 +39,7 @@ import {
import { AdvancedCliSection } from './AdvancedCliSection'; import { AdvancedCliSection } from './AdvancedCliSection';
import { EffortLevelSelector } from './EffortLevelSelector'; import { EffortLevelSelector } from './EffortLevelSelector';
import { OptionalSettingsSection } from './OptionalSettingsSection';
import { ProjectPathSelector } from './ProjectPathSelector'; import { ProjectPathSelector } from './ProjectPathSelector';
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
import { CronScheduleInput } from '../schedule/CronScheduleInput'; import { CronScheduleInput } from '../schedule/CronScheduleInput';
@ -506,6 +507,32 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return args; return args;
}, [isLaunch, skipPermissions, selectedModel, extendedContext, selectedEffort, clearContext]); }, [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 // Validation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -794,7 +821,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
Schedule-only: Schedule configuration section Schedule-only: Schedule configuration section
*/} */}
{isSchedule ? ( {isSchedule ? (
<div className="bg-[var(--color-surface)]/50 rounded-lg border border-[var(--color-border-emphasis)]"> <div className="rounded-lg border border-[var(--color-border-emphasis)] bg-[var(--color-surface-overlay)] shadow-sm">
<button <button
type="button" type="button"
className="flex w-full items-center gap-1.5 px-3 py-2 text-left" className="flex w-full items-center gap-1.5 px-3 py-2 text-left"
@ -861,126 +888,165 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
/> />
{/* {/*
Shared: Prompt (MentionableTextarea for both modes) Launch: optional settings
*/} Schedule: prompt + execution defaults
<div className="space-y-1.5">
<Label htmlFor="dialog-prompt" className={isSchedule ? undefined : 'label-optional'}>
{isSchedule ? <>Prompt</> : 'Prompt for team lead (optional)'}
</Label>
<MentionableTextarea
id="dialog-prompt"
className="min-h-[100px] text-xs"
minRows={4}
maxRows={12}
value={promptDraft.value}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
projectPath={effectiveCwd || null}
chips={chipDraft.chips}
onChipRemove={chipDraft.removeChip}
onFileChipInsert={chipDraft.addChip}
placeholder={
isSchedule
? 'Instructions for Claude to execute on schedule...'
: 'Instructions for team lead...'
}
footerRight={
promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
/>
{isSchedule ? (
<p className="text-[11px] text-[var(--color-text-muted)]">
This prompt will be passed to <code className="font-mono">claude -p</code> for
one-shot execution
</p>
) : null}
</div>
{/*
Shared: Model + Effort + Permissions
*/}
<div>
<TeamModelSelector
value={selectedModel}
onValueChange={setSelectedModel}
id="dialog-model"
/>
<EffortLevelSelector
value={selectedEffort}
onValueChange={setSelectedEffort}
id="dialog-effort"
/>
{/* Extended context — launch only */}
{isLaunch ? (
<ExtendedContextCheckbox
id="launch-extended-context"
checked={extendedContext}
onCheckedChange={setExtendedContext}
disabled={selectedModel === 'haiku'}
/>
) : null}
<SkipPermissionsCheckbox
id="dialog-skip-permissions"
checked={skipPermissions}
onCheckedChange={setSkipPermissions}
/>
</div>
{/*
Launch-only: Clear context + Advanced CLI
*/} */}
{isLaunch ? ( {isLaunch ? (
<> <OptionalSettingsSection
<div className="space-y-2"> title="Optional launch settings"
<div className="flex items-center gap-2"> description="Keep the launch flow focused on the project path and only expand this when you want extra control."
<Checkbox summary={launchOptionalSummary}
id="clear-context" >
checked={clearContext} <div className="space-y-4">
onCheckedChange={(checked) => setClearContext(checked === true)} <div className="space-y-1.5">
/> <Label htmlFor="dialog-prompt" className="label-optional">
<Label Prompt for team lead (optional)
htmlFor="clear-context"
className="flex cursor-pointer items-center gap-1.5 text-xs font-normal text-text-secondary"
>
<RotateCcw className="size-3 shrink-0" />
Clear context (fresh session)
</Label> </Label>
<MentionableTextarea
id="dialog-prompt"
className="min-h-[100px] text-xs"
minRows={4}
maxRows={12}
value={promptDraft.value}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
projectPath={effectiveCwd || null}
chips={chipDraft.chips}
onChipRemove={chipDraft.removeChip}
onFileChipInsert={chipDraft.addChip}
placeholder="Instructions for team lead..."
footerRight={
promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">
Draft saved
</span>
) : null
}
/>
</div> </div>
{clearContext && (
<div <div>
className="rounded-md border px-3 py-2 text-xs" <TeamModelSelector
style={{ value={selectedModel}
backgroundColor: 'var(--warning-bg)', onValueChange={setSelectedModel}
borderColor: 'var(--warning-border)', id="dialog-model"
color: 'var(--warning-text)', />
}} <EffortLevelSelector
> value={selectedEffort}
<div className="flex items-start gap-2"> onValueChange={setSelectedEffort}
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" /> id="dialog-effort"
<p> />
The team lead will start a new session without resuming previous context. <ExtendedContextCheckbox
All accumulated session memory and conversation history will not be id="launch-extended-context"
available. checked={extendedContext}
</p> onCheckedChange={setExtendedContext}
</div> disabled={selectedModel === 'haiku'}
/>
<SkipPermissionsCheckbox
id="dialog-skip-permissions"
checked={skipPermissions}
onCheckedChange={setSkipPermissions}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="clear-context"
checked={clearContext}
onCheckedChange={(checked) => setClearContext(checked === true)}
/>
<Label
htmlFor="clear-context"
className="flex cursor-pointer items-center gap-1.5 text-xs font-normal text-text-secondary"
>
<RotateCcw className="size-3 shrink-0" />
Clear context (fresh session)
</Label>
</div> </div>
)} {clearContext && (
<div
className="rounded-md border px-3 py-2 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<p>
The team lead will start a new session without resuming previous context.
All accumulated session memory and conversation history will not be
available.
</p>
</div>
</div>
)}
</div>
<AdvancedCliSection
teamName={effectiveTeamName}
internalArgs={internalArgs}
worktreeEnabled={worktreeEnabled}
onWorktreeEnabledChange={setWorktreeEnabled}
worktreeName={worktreeName}
onWorktreeNameChange={setWorktreeName}
customArgs={customArgs}
onCustomArgsChange={setCustomArgs}
/>
</div>
</OptionalSettingsSection>
) : (
<>
<div className="space-y-1.5">
<Label htmlFor="dialog-prompt">Prompt</Label>
<MentionableTextarea
id="dialog-prompt"
className="min-h-[100px] text-xs"
minRows={4}
maxRows={12}
value={promptDraft.value}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
projectPath={effectiveCwd || null}
chips={chipDraft.chips}
onChipRemove={chipDraft.removeChip}
onFileChipInsert={chipDraft.addChip}
placeholder="Instructions for Claude to execute on schedule..."
footerRight={
promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">
Draft saved
</span>
) : null
}
/>
<p className="text-[11px] text-[var(--color-text-muted)]">
This prompt will be passed to <code className="font-mono">claude -p</code> for
one-shot execution
</p>
</div> </div>
<AdvancedCliSection <div>
teamName={effectiveTeamName} <TeamModelSelector
internalArgs={internalArgs} value={selectedModel}
worktreeEnabled={worktreeEnabled} onValueChange={setSelectedModel}
onWorktreeEnabledChange={setWorktreeEnabled} id="dialog-model"
worktreeName={worktreeName} />
onWorktreeNameChange={setWorktreeName} <EffortLevelSelector
customArgs={customArgs} value={selectedEffort}
onCustomArgsChange={setCustomArgs} onValueChange={setSelectedEffort}
/> id="dialog-effort"
/>
<SkipPermissionsCheckbox
id="dialog-skip-permissions"
checked={skipPermissions}
onCheckedChange={setSkipPermissions}
/>
</div>
</> </>
) : null} )}
{/* {/*
Schedule-only: Execution limits Schedule-only: Execution limits

View file

@ -0,0 +1,91 @@
import React, { useMemo, useState } from 'react';
import { cn } from '@renderer/lib/utils';
import { ChevronRight, Settings2 } from 'lucide-react';
interface OptionalSettingsSectionProps {
title: string;
description: string;
summary?: string[];
defaultOpen?: boolean;
className?: string;
children: React.ReactNode;
}
export const OptionalSettingsSection = ({
title,
description,
summary = [],
defaultOpen = false,
className,
children,
}: OptionalSettingsSectionProps): React.JSX.Element => {
const [isOpen, setIsOpen] = useState(defaultOpen);
const visibleSummary = useMemo(
() =>
summary
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 4),
[summary]
);
return (
<div
className={cn(
'overflow-hidden rounded-lg border border-[var(--color-border-emphasis)] shadow-sm',
className
)}
style={{
backgroundColor: 'color-mix(in srgb, var(--color-surface-overlay) 94%, white 6%)',
}}
>
<button
type="button"
className="flex w-full items-start justify-between gap-3 px-3 py-3 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
onClick={() => setIsOpen((prev) => !prev)}
aria-expanded={isOpen}
>
<div className="flex min-w-0 items-start gap-2.5">
<div className="mt-0.5 rounded-md border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] p-1.5 text-[var(--color-text-muted)]">
<Settings2 className="size-3.5" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-[var(--color-text)]">{title}</span>
<span className="rounded-full border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] px-2 py-0.5 text-[10px] uppercase tracking-wide text-[var(--color-text-muted)]">
Optional
</span>
</div>
<p className="mt-1 text-xs text-[var(--color-text-muted)]">{description}</p>
{!isOpen ? (
<p className="mt-1.5 line-clamp-2 text-[11px] text-[var(--color-text-secondary)]">
{visibleSummary.length > 0
? visibleSummary.join(' • ')
: 'Collapsed by default to keep the primary flow focused.'}
</p>
) : null}
</div>
</div>
<ChevronRight
className={cn(
'mt-0.5 size-4 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150',
isOpen && 'rotate-90'
)}
/>
</button>
{isOpen ? (
<div
className="border-t border-[var(--color-border-emphasis)] px-3 pb-3 pt-2.5"
style={{
backgroundColor: 'color-mix(in srgb, var(--color-surface-overlay) 86%, white 14%)',
}}
>
{children}
</div>
) : null}
</div>
);
};

View file

@ -73,116 +73,122 @@ export const ProjectPathSelector = ({
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Project</Label> <Label>Project</Label>
<div className="space-y-2"> <div className="space-y-2">
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5"> <div className="flex flex-col gap-2 md:flex-row md:items-start">
<button <div className="inline-flex shrink-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
type="button" <button
className={cn( type="button"
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors', className={cn(
cwdMode === 'project' 'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm' cwdMode === 'project'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]' ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
)} : 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
onClick={() => onCwdModeChange('project')} )}
> onClick={() => onCwdModeChange('project')}
From project list >
</button> From project list
<button </button>
type="button" <button
className={cn( type="button"
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors', className={cn(
cwdMode === 'custom' 'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm' cwdMode === 'custom'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]' ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
)} : 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
onClick={() => onCwdModeChange('custom')} )}
> onClick={() => onCwdModeChange('custom')}
Custom path >
</button> Custom path
</div> </button>
</div>
{cwdMode === 'project' ? ( <div className="min-w-0 flex-1">
<div className="space-y-1.5"> {cwdMode === 'project' ? (
<div className="flex items-center gap-2"> <div className="space-y-1.5">
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" /> <div className="flex items-center gap-2">
<Combobox <FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
options={projects.map((project) => ({ <div className="min-w-0 flex-1">
value: project.path, <Combobox
label: project.name, options={projects.map((project) => ({
description: project.path, value: project.path,
}))} label: project.name,
value={selectedProjectPath} description: project.path,
onValueChange={onSelectedProjectPathChange} }))}
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'} value={selectedProjectPath}
searchPlaceholder="Search project by name or path" onValueChange={onSelectedProjectPathChange}
emptyMessage="Nothing found" placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
disabled={projectsLoading || projects.length === 0} searchPlaceholder="Search project by name or path"
renderOption={(option, isSelected, query) => ( emptyMessage="Nothing found"
<> disabled={projectsLoading || projects.length === 0}
<Check renderOption={(option, isSelected, query) => (
className={cn( <>
'mr-2 size-3.5 shrink-0', <Check
isSelected ? 'opacity-100' : 'opacity-0' className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-[var(--color-text)]">
{renderHighlightedText(option.label, query)}
</p>
<p className="truncate text-[var(--color-text-muted)]">
{renderHighlightedText(option.description ?? '', query)}
</p>
</div>
</>
)} )}
/> />
<div className="min-w-0 flex-1"> </div>
<p className="truncate font-medium text-[var(--color-text)]"> </div>
{renderHighlightedText(option.label, query)} {!selectedProjectPath ? (
</p> <p className="text-[11px] text-[var(--color-text-muted)]">
<p className="truncate text-[var(--color-text-muted)]"> Select a project from the list
{renderHighlightedText(option.description ?? '', query)} </p>
</p> ) : null}
</div> {projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null}
</> {!projectsLoading && projects.length === 0 ? (
)} <p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
/> No projects found, switch to custom path.
</div> </p>
{!selectedProjectPath ? ( ) : null}
<p className="text-[11px] text-[var(--color-text-muted)]"> </div>
Select a project from the list ) : (
</p> <div className="space-y-1.5">
) : null} <div className="flex items-center gap-2">
{projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null} <FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
{!projectsLoading && projects.length === 0 ? ( <Input
<p className="text-[11px]" style={{ color: 'var(--warning-text)' }}> className="h-8 flex-1 text-xs"
No projects found, switch to custom path. value={customCwd}
</p> aria-label="Custom working directory"
) : null} onChange={(event) => onCustomCwdChange(event.target.value)}
placeholder="/absolute/path/to/project"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
void (async () => {
try {
const paths = await api.config.selectFolders();
if (paths.length > 0) {
onCustomCwdChange(paths[0]);
}
} catch {
// IPC error — dialog may have been cancelled or failed
}
})();
}}
>
Browse
</Button>
</div>
<p className="text-[11px] text-[var(--color-text-muted)]">
If the directory does not exist, it will be created automatically.
</p>
</div>
)}
</div> </div>
) : ( </div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
<Input
className="h-8 text-xs"
value={customCwd}
aria-label="Custom working directory"
onChange={(event) => onCustomCwdChange(event.target.value)}
placeholder="/absolute/path/to/project"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
void (async () => {
try {
const paths = await api.config.selectFolders();
if (paths.length > 0) {
onCustomCwdChange(paths[0]);
}
} catch {
// IPC error — dialog may have been cancelled or failed
}
})();
}}
>
Browse
</Button>
</div>
<p className="text-[11px] text-[var(--color-text-muted)]">
If the directory does not exist, it will be created automatically.
</p>
</div>
)}
</div> </div>
{fieldError ? <p className="text-[11px] text-red-300">{fieldError}</p> : null} {fieldError ? <p className="text-[11px] text-red-300">{fieldError}</p> : null}
</div> </div>

View file

@ -9,6 +9,7 @@ import {
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
@ -37,6 +38,7 @@ export const ReviewDialog = ({
onSubmit, onSubmit,
}: ReviewDialogProps): React.JSX.Element => { }: ReviewDialogProps): React.JSX.Element => {
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
const draft = useDraftPersistence({ const draft = useDraftPersistence({
key: `requestChanges:${teamName}:${taskId ?? ''}`, key: `requestChanges:${teamName}:${taskId ?? ''}`,
enabled: Boolean(teamName && taskId), enabled: Boolean(teamName && taskId),
@ -85,6 +87,7 @@ export const ReviewDialog = ({
onValueChange={draft.setValue} onValueChange={draft.setValue}
placeholder="Describe what needs to change... (Enter to submit)" placeholder="Describe what needs to change... (Enter to submit)"
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
projectPath={projectPath} projectPath={projectPath}
onModEnter={handleSubmit} onModEnter={handleSubmit}
minRows={4} minRows={4}

View file

@ -18,6 +18,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { useAttachments } from '@renderer/hooks/useAttachments'; import { useAttachments } from '@renderer/hooks/useAttachments';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
@ -209,6 +210,7 @@ export const SendMessageDialog = ({
); );
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
@ -465,6 +467,7 @@ export const SendMessageDialog = ({
onValueChange={textDraft.setValue} onValueChange={textDraft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
teamSuggestions={teamMentionSuggestions} teamSuggestions={teamMentionSuggestions}
taskSuggestions={taskSuggestions}
chips={chipDraft.chips} chips={chipDraft.chips}
onChipRemove={handleChipRemove} onChipRemove={handleChipRemove}
projectPath={projectPath} projectPath={projectPath}

View file

@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
@ -55,6 +56,7 @@ export const TaskCommentInput = ({
const chipDraft = useChipDraftPersistence(`taskCommentChips:${teamName}:${taskId}`); const chipDraft = useChipDraftPersistence(`taskCommentChips:${teamName}:${taskId}`);
const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]); const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [attachError, setAttachError] = useState<string | null>(null); const [attachError, setAttachError] = useState<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
@ -279,6 +281,7 @@ export const TaskCommentInput = ({
onValueChange={draft.setValue} onValueChange={draft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
teamSuggestions={teamMentionSuggestions} teamSuggestions={teamMentionSuggestions}
taskSuggestions={taskSuggestions}
projectPath={projectPath} projectPath={projectPath}
chips={chipDraft.chips} chips={chipDraft.chips}
onFileChipInsert={chipDraft.addChip} onFileChipInsert={chipDraft.addChip}

View file

@ -13,6 +13,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead'; import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { serializeChipsWithText } from '@renderer/types/inlineChip';
@ -21,6 +22,7 @@ import { isImageMimeType } from '@renderer/utils/attachmentUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
import { MAX_TEXT_LENGTH } from '@shared/constants'; import { MAX_TEXT_LENGTH } from '@shared/constants';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
@ -61,11 +63,6 @@ interface TaskCommentsSectionProps {
unreadCommentIds?: Set<string>; unreadCommentIds?: Set<string>;
} }
/** Convert `#<task-display-id>` 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 = ({ export const TaskCommentsSection = ({
teamName, teamName,
taskId, taskId,
@ -103,6 +100,7 @@ export const TaskCommentsSection = ({
const chipDraft = useChipDraftPersistence(`taskCommentChips:${teamName}:${taskId}`); const chipDraft = useChipDraftPersistence(`taskCommentChips:${teamName}:${taskId}`);
const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
const teamNamesForLinkify = useMemo( const teamNamesForLinkify = useMemo(
() => teamMentionSuggestions.map((t) => t.name), () => teamMentionSuggestions.map((t) => t.name),
[teamMentionSuggestions] [teamMentionSuggestions]
@ -394,6 +392,7 @@ export const TaskCommentsSection = ({
onValueChange={draft.setValue} onValueChange={draft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
teamSuggestions={teamMentionSuggestions} teamSuggestions={teamMentionSuggestions}
taskSuggestions={taskSuggestions}
projectPath={projectPath} projectPath={projectPath}
chips={chipDraft.chips} chips={chipDraft.chips}
onFileChipInsert={chipDraft.addChip} onFileChipInsert={chipDraft.addChip}

View file

@ -541,7 +541,10 @@ export const KanbanBoard = ({
</div> </div>
{viewMode === 'grid' ? ( {viewMode === 'grid' ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5"> <div
className="grid gap-3"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}
>
{visibleColumns.map((column) => { {visibleColumns.map((column) => {
const columnTasks = groupedOrdered.get(column.id) ?? []; const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id]; const accent = COLUMN_ACCENTS[column.id];

View file

@ -8,6 +8,7 @@ import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useComposerDraft } from '@renderer/hooks/useComposerDraft'; import { useComposerDraft } from '@renderer/hooks/useComposerDraft';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
@ -133,6 +134,7 @@ export const MessageComposer = ({
); );
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
const trimmed = draft.text.trim(); const trimmed = draft.text.trim();
@ -757,6 +759,7 @@ export const MessageComposer = ({
onValueChange={draft.setText} onValueChange={draft.setText}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
teamSuggestions={teamMentionSuggestions} teamSuggestions={teamMentionSuggestions}
taskSuggestions={taskSuggestions}
chips={draft.chips} chips={draft.chips}
onChipRemove={draft.removeChip} onChipRemove={draft.removeChip}
projectPath={projectPath} projectPath={projectPath}

View file

@ -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<string>;
/** Current lead session ID. */
currentLeadSessionId?: string;
/** Pending replies tracker (shared with parent for MemberList). */
pendingRepliesByMember: Record<string, number>;
/** Update pending replies tracker. */
onPendingReplyChange: (updater: (prev: Record<string, number>) => Record<string, number>) => 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<MessagesFilterState>({
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<typeof sendTeamMessage>[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 = (
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
<input
type="text"
placeholder="Search..."
value={messagesSearchQuery}
onChange={(e) => 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 && (
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => setMessagesSearchQuery('')}
>
<X size={14} />
</button>
)}
</div>
<MessagesFilterPopover
filter={messagesFilter}
messages={messages}
open={messagesFilterOpen}
onOpenChange={setMessagesFilterOpen}
onApply={setMessagesFilter}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="pointer-events-auto size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
setMessagesCollapsed((v) => !v);
}}
>
{messagesCollapsed ? <ChevronsUpDown size={14} /> : <ChevronsDownUp size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
</TooltipContent>
</Tooltip>
</div>
);
const messagesContent = (
<>
<MessageComposer
teamName={teamName}
members={members}
isTeamAlive={isTeamAlive}
sending={sendingMessage}
sendError={sendMessageError}
lastResult={lastSendMessageResult}
onSend={handleSend}
onCrossTeamSend={handleCrossTeamSend}
/>
{/* Status block: button floats right (absolute, no layout impact);
expanded content renders full-width in normal flow. */}
{hasStatusItems && (
<>
<div className="relative h-0">
<button
type="button"
className="absolute -top-[19px] right-0 z-10 flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={() => setStatusBlockCollapsed((prev) => !prev)}
aria-label={statusBlockCollapsed ? 'Expand status' : 'Collapse status'}
>
<ChevronRight
size={12}
className={`shrink-0 transition-transform duration-150 ${statusBlockCollapsed ? '' : 'rotate-90'}`}
/>
Status
</button>
</div>
{!statusBlockCollapsed && (
<div className="mt-5">
<PendingRepliesBlock
members={members}
pendingRepliesByMember={pendingRepliesByMember}
pendingCrossTeamReplies={pendingCrossTeamReplies}
onMemberClick={onMemberClick}
/>
<ActiveTasksBlock
members={members}
tasks={tasks}
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>
</div>
)}
</>
)}
<ActivityTimeline
messages={filteredMessages}
teamName={teamName}
members={members}
readState={{ readSet, getMessageKey: toMessageKey }}
allCollapsed={messagesCollapsed}
expandOverrides={expandedSet}
onToggleExpandOverride={toggleExpandOverride}
teamSessionIds={teamSessionIds}
currentLeadSessionId={currentLeadSessionId}
onMemberClick={onMemberClick}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMessageVisible={handleMessageVisible}
onRestartTeam={onRestartTeam}
onTaskIdClick={onTaskIdClick}
/>
</>
);
// ---- Sidebar mode ----
if (position === 'sidebar') {
return (
<div className="flex size-full flex-col overflow-hidden bg-[var(--color-surface)]">
{/* Header */}
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--color-border)] px-3 py-2">
<MessageSquare size={14} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="text-sm font-medium text-[var(--color-text)]">Messages</span>
{filteredMessages.length > 0 && (
<Badge
variant="secondary"
className="px-1.5 py-0.5 text-[10px] font-normal leading-none"
>
{filteredMessages.length}
</Badge>
)}
{messagesUnreadCount > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="secondary"
className="bg-blue-500/20 px-1.5 py-0.5 text-[10px] font-normal leading-none text-blue-600 dark:text-blue-400"
>
{messagesUnreadCount} new
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom">{messagesUnreadCount} unread</TooltipContent>
</Tooltip>
)}
{messagesUnreadCount > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex items-center gap-1 rounded-md px-1.5 py-1 text-[11px] text-blue-400 transition-colors hover:bg-blue-500/10"
onClick={handleMarkAllRead}
>
<CheckCheck size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Mark all as read</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={() => {
void window.electronAPI.openExternal(
'https://github.com/777genius/claude-notifications-go'
);
}}
>
<Bell size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Desktop notifications plugin</TooltipContent>
</Tooltip>
<div className="ml-auto">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={onTogglePosition}
>
<PanelLeftClose size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Move to inline</TooltipContent>
</Tooltip>
</div>
</div>
{/* Search & filter bar */}
<div className="shrink-0 border-b border-[var(--color-border)] px-3 py-1.5">
{searchAndFilterBar}
</div>
{/* Scrollable content */}
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-2">{messagesContent}</div>
</div>
);
}
// ---- Inline mode (wrapped in CollapsibleTeamSection) ----
return (
<CollapsibleTeamSection
sectionId="messages"
title="Messages"
icon={<MessageSquare size={14} />}
badge={filteredMessages.length}
secondaryBadge={
filteredMessages.length > 0 && messagesUnreadCount > 0 ? messagesUnreadCount : undefined
}
afterBadge={
messagesUnreadCount > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="pointer-events-auto flex items-center gap-1 rounded-md px-1.5 py-1 text-[11px] text-blue-400 transition-colors hover:bg-blue-500/10"
onClick={(e) => {
e.stopPropagation();
handleMarkAllRead();
}}
>
<CheckCheck size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Mark all as read</TooltipContent>
</Tooltip>
) : undefined
}
headerExtra={
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
void window.electronAPI.openExternal(
'https://github.com/777genius/claude-notifications-go'
);
}}
>
<Bell size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Desktop notifications plugin</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
onTogglePosition();
}}
>
<PanelLeft size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Move to sidebar</TooltipContent>
</Tooltip>
</>
}
defaultOpen
action={<div className="flex items-center gap-2 pl-2 pr-2">{searchAndFilterBar}</div>}
>
{messagesContent}
</CollapsibleTeamSection>
);
};

View file

@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react';
import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { nameColorSet } from '@renderer/utils/projectColor'; 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'; import type { MentionSuggestion } from '@renderer/types/mention';
@ -67,17 +67,23 @@ export const MentionSuggestionList = ({
}, [selectedIndex]); }, [selectedIndex]);
if (suggestions.length === 0) { if (suggestions.length === 0) {
const emptyStateText = filesLoading
? 'Searching...'
: hasFileSearch
? 'No matching suggestions'
: 'No matching suggestions';
return ( return (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] px-3 py-2 text-xs text-[var(--color-text-muted)]"> <div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] px-3 py-2 text-xs text-[var(--color-text-muted)]">
{hasFileSearch ? 'No matching members, teams, or files' : 'No matching members'} {emptyStateText}
</div> </div>
); );
} }
// Categorize suggestions (folders are grouped with files) // Categorize suggestions (folders are grouped with files)
type Section = 'member' | 'team' | 'file'; type Section = 'member' | 'team' | 'task' | 'file';
const getSuggestionSection = (s: MentionSuggestion): Section => { const getSuggestionSection = (s: MentionSuggestion): Section => {
if (s.type === 'file' || s.type === 'folder') return 'file'; if (s.type === 'file' || s.type === 'folder') return 'file';
if (s.type === 'task') return 'task';
if (s.type === 'team') return 'team'; if (s.type === 'team') return 'team';
return 'member'; return 'member';
}; };
@ -85,6 +91,7 @@ export const MentionSuggestionList = ({
const sectionLabel: Record<Section, string> = { const sectionLabel: Record<Section, string> = {
member: 'Members', member: 'Members',
team: 'Teams', team: 'Teams',
task: 'Tasks',
file: 'Files', file: 'Files',
}; };
@ -103,6 +110,7 @@ export const MentionSuggestionList = ({
const isFolder = s.type === 'folder'; const isFolder = s.type === 'folder';
const isFileOrFolder = isFile || isFolder; const isFileOrFolder = isFile || isFolder;
const isTeam = section === 'team'; const isTeam = section === 'team';
const isTask = section === 'task';
// Insert section header on transition // Insert section header on transition
if (showSections && section !== currentSection) { if (showSections && section !== currentSection) {
@ -141,6 +149,8 @@ export const MentionSuggestionList = ({
<Folder size={14} className="shrink-0 text-[var(--color-text-muted)]" /> <Folder size={14} className="shrink-0 text-[var(--color-text-muted)]" />
) : isFile ? ( ) : isFile ? (
<FileIcon fileName={s.name} className="size-3.5" /> <FileIcon fileName={s.name} className="size-3.5" />
) : isTask ? (
<Hash size={13} className="shrink-0 text-blue-500 dark:text-blue-400" />
) : isTeam ? ( ) : isTeam ? (
<UsersRound <UsersRound
size={13} size={13}
@ -153,12 +163,30 @@ export const MentionSuggestionList = ({
style={{ backgroundColor: colorSet?.border ?? 'var(--color-text-muted)' }} style={{ backgroundColor: colorSet?.border ?? 'var(--color-text-muted)' }}
/> />
)} )}
<span <div className="min-w-0 flex-1">
className={isFileOrFolder ? 'truncate' : 'font-medium'} <div className="flex items-center gap-2">
style={colorSet ? { color: colorSet.text } : undefined} <span
> className={isFileOrFolder ? 'truncate' : 'font-medium'}
<HighlightedName name={s.name} query={query} /> style={
</span> isTask
? { color: 'var(--color-link, #60a5fa)' }
: colorSet
? { color: colorSet.text }
: undefined
}
>
<HighlightedName name={isTask ? `#${s.name}` : s.name} query={query} />
</span>
{isTask && !s.isCurrentTeamTask && s.teamDisplayName ? (
<span className="truncate text-[10px] text-[var(--color-text-muted)]">
{s.teamDisplayName}
</span>
) : null}
</div>
{isTask && s.subtitle ? (
<div className="truncate text-[10px] text-[var(--color-text-muted)]">{s.subtitle}</div>
) : null}
</div>
{isTeam && s.isOnline !== undefined ? ( {isTeam && s.isOnline !== undefined ? (
<span <span
className="inline-block size-1.5 shrink-0 rounded-full" className="inline-block size-1.5 shrink-0 rounded-full"
@ -166,7 +194,7 @@ export const MentionSuggestionList = ({
title={s.isOnline ? 'Online' : 'Offline'} title={s.isOnline ? 'Online' : 'Offline'}
/> />
) : null} ) : null}
{s.subtitle ? ( {s.subtitle && !isTask ? (
<span className="truncate text-[var(--color-text-muted)]">{s.subtitle}</span> <span className="truncate text-[var(--color-text-muted)]">{s.subtitle}</span>
) : null} ) : null}
</li> </li>

View file

@ -1,12 +1,18 @@
import * as React from 'react'; import * as React from 'react';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { PROSE_LINK } from '@renderer/constants/cssVariables';
import { useFileSuggestions } from '@renderer/hooks/useFileSuggestions'; import { useFileSuggestions } from '@renderer/hooks/useFileSuggestions';
import { useMentionDetection } from '@renderer/hooks/useMentionDetection'; import { useMentionDetection } from '@renderer/hooks/useMentionDetection';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { chipToken } from '@renderer/types/inlineChip'; import { chipToken } from '@renderer/types/inlineChip';
import {
doesSuggestionMatchQuery,
getSuggestionInsertionText,
} from '@renderer/utils/mentionSuggestions';
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
import { findTaskReferenceMatches } from '@renderer/utils/taskReferenceUtils';
import { import {
createChipFromSelection, createChipFromSelection,
findChipBoundary, findChipBoundary,
@ -18,6 +24,7 @@ import { AutoResizeTextarea } from './auto-resize-textarea';
import { ChipInteractionLayer } from './ChipInteractionLayer'; import { ChipInteractionLayer } from './ChipInteractionLayer';
import { CodeChipBadge } from './CodeChipBadge'; import { CodeChipBadge } from './CodeChipBadge';
import { MentionSuggestionList } from './MentionSuggestionList'; import { MentionSuggestionList } from './MentionSuggestionList';
import { TaskReferenceInteractionLayer } from './TaskReferenceInteractionLayer';
import type { AutoResizeTextareaProps } from './auto-resize-textarea'; import type { AutoResizeTextareaProps } from './auto-resize-textarea';
import type { InlineChip } from '@renderer/types/inlineChip'; import type { InlineChip } from '@renderer/types/inlineChip';
@ -38,13 +45,19 @@ interface MentionSegment {
suggestion: MentionSuggestion; suggestion: MentionSuggestion;
} }
interface TaskSegment {
type: 'task';
value: string;
suggestion: MentionSuggestion;
}
interface ChipSegment { interface ChipSegment {
type: 'chip'; type: 'chip';
value: string; value: string;
chip: InlineChip; chip: InlineChip;
} }
type Segment = TextSegment | MentionSegment | ChipSegment; type Segment = TextSegment | MentionSegment | TaskSegment | ChipSegment;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mention segment parsing (splits text into plain text + @mention segments) // 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 }]; if (!text || suggestions.length === 0) return [{ type: 'text', value: text }];
// Sort by name length descending for greedy matching // 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[] = []; const segments: Segment[] = [];
let i = 0; let i = 0;
@ -86,9 +101,10 @@ function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): S
let matched = false; let matched = false;
for (const suggestion of sorted) { 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 (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 // Character after name must be boundary
if (end < text.length) { if (end < text.length) {
@ -119,6 +135,40 @@ function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): S
return segments; 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 // Extended segment parser: chips + mentions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -129,11 +179,12 @@ function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): S
*/ */
function parseSegments( function parseSegments(
text: string, text: string,
suggestions: MentionSuggestion[], mentionSuggestions: MentionSuggestion[],
taskSuggestions: MentionSuggestion[],
chips: InlineChip[] chips: InlineChip[]
): Segment[] { ): Segment[] {
if (!text) return [{ type: 'text', value: text }]; 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 // Build a map of chip tokens for fast lookup
const chipTokenMap = new Map<string, InlineChip>(); const chipTokenMap = new Map<string, InlineChip>();
@ -154,7 +205,9 @@ function parseSegments(
} }
chipPositions.sort((a, b) => a.start - b.start); 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[] = []; const segments: Segment[] = [];
let lastEnd = 0; let lastEnd = 0;
@ -163,7 +216,7 @@ function parseSegments(
// Text before this chip → parse for mentions // Text before this chip → parse for mentions
if (pos.start > lastEnd) { if (pos.start > lastEnd) {
const fragment = text.slice(lastEnd, pos.start); 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 }); segments.push({ type: 'chip', value: pos.token, chip: pos.chip });
lastEnd = pos.end; lastEnd = pos.end;
@ -171,7 +224,9 @@ function parseSegments(
// Remaining text after last chip → parse for mentions // Remaining text after last chip → parse for mentions
if (lastEnd < text.length) { if (lastEnd < text.length) {
segments.push(...parseMentionSegments(text.slice(lastEnd), suggestions)); segments.push(
...parseSuggestionSegments(text.slice(lastEnd), mentionSuggestions, taskSuggestions)
);
} }
return segments; return segments;
@ -210,6 +265,8 @@ interface MentionableTextareaProps extends Omit<
onFileChipInsert?: (chip: InlineChip) => void; onFileChipInsert?: (chip: InlineChip) => void;
/** Team suggestions for cross-team @mentions */ /** Team suggestions for cross-team @mentions */
teamSuggestions?: MentionSuggestion[]; teamSuggestions?: MentionSuggestion[];
/** Task suggestions for #task references */
taskSuggestions?: MentionSuggestion[];
/** Called when Enter (without Shift) is pressed. */ /** Called when Enter (without Shift) is pressed. */
onModEnter?: () => void; onModEnter?: () => void;
} }
@ -230,6 +287,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
projectPath, projectPath,
onFileChipInsert, onFileChipInsert,
teamSuggestions = [], teamSuggestions = [],
taskSuggestions = [],
onModEnter, onModEnter,
style, style,
className, className,
@ -244,6 +302,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
// --- File search activation --- // --- File search activation ---
const enableFiles = !!projectPath; const enableFiles = !!projectPath;
const enableTaskSearch = taskSuggestions.length > 0;
const setRefs = React.useCallback( const setRefs = React.useCallback(
(node: HTMLTextAreaElement | null) => { (node: HTMLTextAreaElement | null) => {
@ -260,9 +319,10 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
const { const {
isOpen, isOpen,
activeTriggerChar,
query, query,
filteredSuggestions: memberSuggestions,
selectedIndex, selectedIndex,
setSelectedIndex,
dropdownPosition, dropdownPosition,
selectSuggestion, selectSuggestion,
dismiss, dismiss,
@ -271,30 +331,46 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
handleChange: mentionHandleChange, handleChange: mentionHandleChange,
handleSelect: mentionHandleSelect, handleSelect: mentionHandleSelect,
} = useMentionDetection({ } = useMentionDetection({
suggestions,
value, value,
onValueChange, onValueChange,
textareaRef: internalRef, textareaRef: internalRef,
enableTriggerAlways: enableFiles || teamSuggestions.length > 0, triggerChars: enableTaskSearch ? ['@', '#'] : ['@'],
isTriggerEnabled: (triggerChar) => {
if (triggerChar === '#') return enableTaskSearch;
return suggestions.length > 0 || enableFiles || teamSuggestions.length > 0;
},
}); });
// --- File suggestions --- // --- File suggestions ---
const { suggestions: fileSuggestions, loading: filesLoading } = useFileSuggestions( const { suggestions: fileSuggestions, loading: filesLoading } = useFileSuggestions(
enableFiles ? projectPath : null, enableFiles ? projectPath : null,
query, activeTriggerChar === '@' ? query : '',
isOpen && enableFiles 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 --- // --- Team suggestions filtered by query ---
const filteredTeamSuggestions = React.useMemo(() => { const filteredTeamSuggestions = React.useMemo(() => {
if (teamSuggestions.length === 0 || !isOpen) return []; if (teamSuggestions.length === 0 || !isOpen || !isAtTrigger) return [];
if (!query) return teamSuggestions; if (!query) return teamSuggestions;
const lower = query.toLowerCase(); return teamSuggestions.filter((team) => doesSuggestionMatchQuery(team, query));
return teamSuggestions.filter((t) => t.name.toLowerCase().includes(lower)); }, [teamSuggestions, isAtTrigger, isOpen, query]);
}, [teamSuggestions, 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 // 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 onlineTeams = filteredTeamSuggestions.filter((t) => t.isOnline);
const offlineTeams = filteredTeamSuggestions.filter((t) => !t.isOnline); const offlineTeams = filteredTeamSuggestions.filter((t) => !t.isOnline);
const merged = [...memberSuggestions, ...onlineTeams, ...offlineTeams]; const merged = [...memberSuggestions, ...onlineTeams, ...offlineTeams];
@ -302,21 +378,19 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
if (fileSuggestions.length === 0) return merged; if (fileSuggestions.length === 0) return merged;
return [...merged, ...fileSuggestions]; return [...merged, ...fileSuggestions];
}, [memberSuggestions, filteredTeamSuggestions, enableFiles, fileSuggestions]); }, [memberSuggestions, filteredTeamSuggestions, enableFiles, fileSuggestions]);
const effectiveSuggestions =
activeTriggerChar === '#' ? filteredTaskSuggestions : atSuggestions;
// When files are enabled, manage our own selectedIndex for the merged list
const [mergedIndex, setMergedIndex] = React.useState(0);
// Reset merged index when suggestions change or query changes
React.useEffect(() => { React.useEffect(() => {
setMergedIndex(0); if (!isOpen) return;
}, [query, allSuggestions.length]); if (effectiveSuggestions.length === 0) {
setSelectedIndex(0);
// Use merged index when we have extra suggestion types (teams or files) return;
const hasMergedSuggestions = enableFiles || teamSuggestions.length > 0; }
if (selectedIndex >= effectiveSuggestions.length) {
// Effective index: use merged when extra types present, hook's index otherwise setSelectedIndex(0);
const effectiveIndex = hasMergedSuggestions ? mergedIndex : selectedIndex; }
const effectiveSuggestions = hasMergedSuggestions ? allSuggestions : memberSuggestions; }, [effectiveSuggestions.length, isOpen, selectedIndex, setSelectedIndex]);
// --- File selection handler --- // --- File selection handler ---
const handleFileSelect = React.useCallback( const handleFileSelect = React.useCallback(
@ -436,8 +510,8 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
[getTriggerIndex, query, value, chips, onValueChange, onFileChipInsert, onChipRemove, dismiss] [getTriggerIndex, query, value, chips, onValueChange, onFileChipInsert, onChipRemove, dismiss]
); );
// --- Merged selection handler --- // --- Active selection handler ---
const handleMergedSelect = React.useCallback( const handleActiveSelect = React.useCallback(
(s: MentionSuggestion) => { (s: MentionSuggestion) => {
if (s.type === 'file') { if (s.type === 'file') {
handleFileSelect(s); handleFileSelect(s);
@ -465,17 +539,22 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
}, [value]); }, [value]);
// --- Overlay activation --- // --- Overlay activation ---
const hasOverlay = suggestions.length > 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 // Combine member + team suggestions for overlay parsing
const allOverlaySuggestions = React.useMemo( const mentionOverlaySuggestions = React.useMemo(
() => (teamSuggestions.length > 0 ? [...suggestions, ...teamSuggestions] : suggestions), () => (teamSuggestions.length > 0 ? [...suggestions, ...teamSuggestions] : suggestions),
[suggestions, teamSuggestions] [suggestions, teamSuggestions]
); );
const segments = React.useMemo( 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 // Sync backdrop scroll with textarea scroll + track scrollTop for interaction layer
@ -561,47 +640,15 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
[chips, onChipRemove, value, onValueChange] [chips, onChipRemove, value, onValueChange]
); );
// --- File-aware keyboard handler (replaces mention handler when files enabled) --- // Composed key handler: suggestion logic first (when open) → Mod+Enter submit → chip logic
const fileMentionHandleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
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
const composedHandleKeyDown = React.useCallback( const composedHandleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// 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 (isOpen && effectiveSuggestions.length > 0) {
if (hasMergedSuggestions) { mentionHandleKeyDown(e, effectiveSuggestions.length, (index) => {
fileMentionHandleKeyDown(e); const next = effectiveSuggestions[index];
} else { if (next) handleActiveSelect(next);
mentionHandleKeyDown(e); });
}
if (e.defaultPrevented) return; if (e.defaultPrevented) return;
} }
// Enter (without Shift) → submit; Shift+Enter → newline // Enter (without Shift) → submit; Shift+Enter → newline
@ -611,22 +658,15 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
return; return;
} }
handleChipKeyDown(e); handleChipKeyDown(e);
if (!e.defaultPrevented && !isOpen) {
if (hasMergedSuggestions) {
fileMentionHandleKeyDown(e);
} else {
mentionHandleKeyDown(e);
}
}
}, },
[ [
onModEnter, onModEnter,
handleChipKeyDown, handleChipKeyDown,
hasMergedSuggestions,
fileMentionHandleKeyDown,
mentionHandleKeyDown, mentionHandleKeyDown,
isOpen, isOpen,
effectiveSuggestions.length, effectiveSuggestions.length,
effectiveSuggestions,
handleActiveSelect,
] ]
); );
@ -707,7 +747,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
// --- Rotating tips --- // --- Rotating tips ---
const rotatingTips = React.useMemo( const rotatingTips = React.useMemo(
() => [ () => [
'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: 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", "Tip: Don't overload the team lead with tasks — ask them to delegate to teammates",
], ],
@ -731,7 +771,8 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
const resolvedHintText = hintText ?? rotatingTips[tipIndex]; const resolvedHintText = hintText ?? rotatingTips[tipIndex];
const showHintRow = const showHintRow =
showHint && (suggestions.length > 0 || enableFiles || teamSuggestions.length > 0); showHint &&
(suggestions.length > 0 || enableFiles || teamSuggestions.length > 0 || enableTaskSearch);
const showFooter = showHintRow || footerRight; const showFooter = showHintRow || footerRight;
return ( return (
@ -759,6 +800,17 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
if (seg.type === 'chip') { if (seg.type === 'chip') {
return <CodeChipBadge key={idx} chip={seg.chip} tokenText={seg.value} />; return <CodeChipBadge key={idx} chip={seg.chip} tokenText={seg.value} />;
} }
if (seg.type === 'task') {
return (
<span
key={idx}
className="font-medium underline decoration-transparent"
style={{ color: PROSE_LINK }}
>
{seg.value}
</span>
);
}
// mention (member or team) // mention (member or team)
const isTeamMention = seg.suggestion.type === 'team'; const isTeamMention = seg.suggestion.type === 'team';
const colorSet = seg.suggestion.color const colorSet = seg.suggestion.color
@ -785,6 +837,15 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
</div> </div>
) : null} ) : null}
{taskSuggestions.length > 0 ? (
<TaskReferenceInteractionLayer
taskSuggestions={taskSuggestions}
value={value}
textareaRef={internalRef}
scrollTop={scrollTop}
/>
) : null}
<AutoResizeTextarea <AutoResizeTextarea
ref={setRefs} ref={setRefs}
value={value} value={value}
@ -839,11 +900,11 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
<div className="absolute left-0 z-50 w-full" style={{ top: `${dropdownPosition.top}px` }}> <div className="absolute left-0 z-50 w-full" style={{ top: `${dropdownPosition.top}px` }}>
<MentionSuggestionList <MentionSuggestionList
suggestions={effectiveSuggestions} suggestions={effectiveSuggestions}
selectedIndex={effectiveIndex} selectedIndex={selectedIndex}
onSelect={hasMergedSuggestions ? handleMergedSelect : selectSuggestion} onSelect={handleActiveSelect}
query={query} query={query}
hasFileSearch={enableFiles} hasFileSearch={enableFiles}
filesLoading={enableFiles && filesLoading} filesLoading={enableFiles && filesLoading && activeTriggerChar === '@'}
/> />
</div> </div>
) : null} ) : null}

View file

@ -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<HTMLTextAreaElement | null>;
scrollTop: number;
}
type PositionedTaskReference = InlineMatchPosition<MentionSuggestion>;
export const TaskReferenceInteractionLayer = ({
taskSuggestions,
value,
textareaRef,
scrollTop,
}: TaskReferenceInteractionLayerProps): React.JSX.Element | null => {
const [positions, setPositions] = React.useState<PositionedTaskReference[]>([]);
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 (
<div className="pointer-events-none absolute inset-0 z-20 overflow-hidden">
<div style={{ transform: `translateY(-${scrollTop}px)` }}>
{positions.map((position, index) => {
const suggestion = position.item;
const taskId = suggestion.taskId;
const teamName = suggestion.teamName;
if (!taskId) return null;
return (
<TaskTooltip
key={`${suggestion.id}:${position.start}:${index}`}
taskId={taskId}
teamName={teamName}
>
<button
type="button"
className="pointer-events-auto absolute cursor-pointer rounded-sm bg-transparent p-0"
style={{
top: position.top,
left: position.left,
width: position.width,
height: position.height,
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (teamName) {
openGlobalTaskDetail(teamName, taskId);
}
}}
aria-label={`Open task ${position.token}`}
/>
</TaskTooltip>
);
})}
</div>
</div>
);
};

View file

@ -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'; import type { MentionSuggestion } from '@renderer/types/mention';
interface UseMentionDetectionOptions { interface UseMentionDetectionOptions {
suggestions: MentionSuggestion[];
value: string; value: string;
onValueChange: (v: string) => void; onValueChange: (v: string) => void;
textareaRef: React.RefObject<HTMLTextAreaElement | null>; textareaRef: React.RefObject<HTMLTextAreaElement | null>;
/** When true, detect @-trigger even if suggestions list is empty (e.g. for file-only search) */ /** Supported trigger characters, e.g. ['@', '#'] */
enableTriggerAlways?: boolean; triggerChars?: string[];
/** Enable or disable individual triggers dynamically. */
isTriggerEnabled?: (triggerChar: string) => boolean;
} }
export interface DropdownPosition { export interface DropdownPosition {
@ -18,13 +21,18 @@ export interface DropdownPosition {
interface UseMentionDetectionResult { interface UseMentionDetectionResult {
isOpen: boolean; isOpen: boolean;
activeTriggerChar: string | null;
query: string; query: string;
filteredSuggestions: MentionSuggestion[];
selectedIndex: number; selectedIndex: number;
setSelectedIndex: Dispatch<SetStateAction<number>>;
dropdownPosition: DropdownPosition | null; dropdownPosition: DropdownPosition | null;
selectSuggestion: (s: MentionSuggestion) => void; selectSuggestion: (s: MentionSuggestion) => void;
dismiss: () => void; dismiss: () => void;
handleKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void; handleKeyDown: (
e: React.KeyboardEvent<HTMLTextAreaElement>,
suggestionCount: number,
onSelectSuggestion: (index: number) => void
) => void;
handleChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void; handleChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleSelect: (e: React.SyntheticEvent<HTMLTextAreaElement>) => void; handleSelect: (e: React.SyntheticEvent<HTMLTextAreaElement>) => void;
/** Getter for trigger index — use at call time to avoid stale closure (returns -1 if no active trigger) */ /** 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 { interface MentionTrigger {
triggerIndex: number; triggerIndex: number;
triggerChar: string;
query: 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. * Returns null if no valid trigger found.
* *
* Rules: * Rules:
* - @ must be at start of text or preceded by whitespace * - trigger must be at start of text or preceded by whitespace
* - Text between @ and cursor must not contain spaces * - 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; if (cursorPos <= 0) return null;
const beforeCursor = text.slice(0, cursorPos); const beforeCursor = text.slice(0, cursorPos);
const allowedTriggerChars = new Set(triggerChars);
// Scan backwards to find @ // Scan backwards to find @
for (let i = beforeCursor.length - 1; i >= 0; i--) { for (let i = beforeCursor.length - 1; i >= 0; i--) {
const char = beforeCursor[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 === ' ' || char === '\t' || char === '\n' || char === '\r') return null;
if (char === '@') { if (allowedTriggerChars.has(char)) {
// @ must be at start or after whitespace/newline // trigger must be at start or after whitespace/newline
if (i > 0) { if (i > 0) {
const preceding = beforeCursor[i - 1]; const preceding = beforeCursor[i - 1];
if (preceding !== ' ' && preceding !== '\t' && preceding !== '\n' && preceding !== '\r') { 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); 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({ export function useMentionDetection({
suggestions,
value, value,
onValueChange, onValueChange,
textareaRef, textareaRef,
enableTriggerAlways, triggerChars = ['@'],
isTriggerEnabled,
}: UseMentionDetectionOptions): UseMentionDetectionResult { }: UseMentionDetectionOptions): UseMentionDetectionResult {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [activeTriggerChar, setActiveTriggerChar] = useState<string | null>(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null); const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
const triggerIndexRef = useRef<number>(-1); const triggerIndexRef = useRef<number>(-1);
const activeTriggerCharRef = useRef<string | null>(null);
// Track current query in a ref so detectTrigger can avoid resetting selectedIndex // Track current query in a ref so detectTrigger can avoid resetting selectedIndex
// on redundant selectionchange events (e.g. after ArrowDown/Up keyboard navigation) // on redundant selectionchange events (e.g. after ArrowDown/Up keyboard navigation)
const queryRef = useRef(''); 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(() => { const dismiss = useCallback(() => {
setIsOpen(false); setIsOpen(false);
setActiveTriggerChar(null);
setQuery(''); setQuery('');
setSelectedIndex(0); setSelectedIndex(0);
setDropdownPosition(null); setDropdownPosition(null);
triggerIndexRef.current = -1; triggerIndexRef.current = -1;
activeTriggerCharRef.current = null;
queryRef.current = ''; queryRef.current = '';
}, []); }, []);
@ -201,11 +212,12 @@ export function useMentionDetection({
const selectSuggestion = useCallback( const selectSuggestion = useCallback(
(s: MentionSuggestion) => { (s: MentionSuggestion) => {
const textarea = textareaRef.current; 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 before = value.slice(0, triggerIndexRef.current);
const after = value.slice(triggerIndexRef.current + 1 + query.length); const after = value.slice(triggerIndexRef.current + 1 + queryRef.current.length);
const insertion = `@${s.name} `; const insertion = `${triggerChar}${getSuggestionInsertionText(s)} `;
const newValue = before + insertion + after; const newValue = before + insertion + after;
const newCursorPos = before.length + insertion.length; const newCursorPos = before.length + insertion.length;
@ -218,11 +230,11 @@ export function useMentionDetection({
textarea.selectionEnd = newCursorPos; 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 * Called from handleSelect (selectionchange) must NOT reset selectedIndex when
* the trigger is already active with the same query, otherwise ArrowDown/Up navigation * the trigger is already active with the same query, otherwise ArrowDown/Up navigation
@ -230,12 +242,17 @@ export function useMentionDetection({
*/ */
const detectTrigger = useCallback( const detectTrigger = useCallback(
(cursorPos: number) => { (cursorPos: number) => {
const trigger = findMentionTrigger(value, cursorPos); const trigger = findMentionTrigger(value, cursorPos, triggerChars);
if (trigger && (suggestions.length > 0 || enableTriggerAlways)) { const isEnabled = trigger ? (isTriggerEnabled?.(trigger.triggerChar) ?? true) : false;
if (trigger && isEnabled) {
const sameQuery = 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; triggerIndexRef.current = trigger.triggerIndex;
activeTriggerCharRef.current = trigger.triggerChar;
queryRef.current = trigger.query; queryRef.current = trigger.query;
setActiveTriggerChar(trigger.triggerChar);
setQuery(trigger.query); setQuery(trigger.query);
setIsOpen(true); setIsOpen(true);
// Only reset selection when trigger/query actually changed — // Only reset selection when trigger/query actually changed —
@ -248,7 +265,7 @@ export function useMentionDetection({
dismiss(); dismiss();
} }
}, },
[value, suggestions.length, enableTriggerAlways, dismiss, computeDropdownPosition] [value, triggerChars, isTriggerEnabled, dismiss, computeDropdownPosition]
); );
const handleChange = useCallback( const handleChange = useCallback(
@ -258,10 +275,13 @@ export function useMentionDetection({
// Detect trigger based on cursor position after the change // Detect trigger based on cursor position after the change
const cursorPos = e.target.selectionStart; const cursorPos = e.target.selectionStart;
const trigger = findMentionTrigger(newValue, cursorPos); const trigger = findMentionTrigger(newValue, cursorPos, triggerChars);
if (trigger && (suggestions.length > 0 || enableTriggerAlways)) { const isEnabled = trigger ? (isTriggerEnabled?.(trigger.triggerChar) ?? true) : false;
if (trigger && isEnabled) {
triggerIndexRef.current = trigger.triggerIndex; triggerIndexRef.current = trigger.triggerIndex;
activeTriggerCharRef.current = trigger.triggerChar;
queryRef.current = trigger.query; queryRef.current = trigger.query;
setActiveTriggerChar(trigger.triggerChar);
setQuery(trigger.query); setQuery(trigger.query);
setIsOpen(true); setIsOpen(true);
// Text changed — always reset selection to first item // Text changed — always reset selection to first item
@ -271,7 +291,7 @@ export function useMentionDetection({
dismiss(); dismiss();
} }
}, },
[onValueChange, suggestions.length, enableTriggerAlways, dismiss, computeDropdownPosition] [onValueChange, triggerChars, isTriggerEnabled, dismiss, computeDropdownPosition]
); );
const handleSelect = useCallback( const handleSelect = useCallback(
@ -283,24 +303,26 @@ export function useMentionDetection({
); );
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { (
if (!isOpen || filteredSuggestions.length === 0) return; e: React.KeyboardEvent<HTMLTextAreaElement>,
suggestionCount: number,
onSelectSuggestion: (index: number) => void
) => {
if (!isOpen || suggestionCount === 0) return;
switch (e.key) { switch (e.key) {
case 'ArrowDown': case 'ArrowDown':
e.preventDefault(); e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % filteredSuggestions.length); setSelectedIndex((prev) => (prev + 1) % suggestionCount);
break; break;
case 'ArrowUp': case 'ArrowUp':
e.preventDefault(); e.preventDefault();
setSelectedIndex( setSelectedIndex((prev) => (prev - 1 + suggestionCount) % suggestionCount);
(prev) => (prev - 1 + filteredSuggestions.length) % filteredSuggestions.length
);
break; break;
case 'Enter': case 'Enter':
if (!e.shiftKey) { if (!e.shiftKey) {
e.preventDefault(); e.preventDefault();
selectSuggestion(filteredSuggestions[selectedIndex]); onSelectSuggestion(selectedIndex);
} }
break; break;
case 'Escape': case 'Escape':
@ -309,16 +331,17 @@ export function useMentionDetection({
break; break;
} }
}, },
[isOpen, filteredSuggestions, selectedIndex, selectSuggestion, dismiss] [isOpen, selectedIndex, dismiss]
); );
const getTriggerIndex = useCallback(() => triggerIndexRef.current, []); const getTriggerIndex = useCallback(() => triggerIndexRef.current, []);
return { return {
isOpen, isOpen,
activeTriggerChar,
query, query,
filteredSuggestions,
selectedIndex, selectedIndex,
setSelectedIndex,
dropdownPosition, dropdownPosition,
selectSuggestion, selectSuggestion,
dismiss, dismiss,

View file

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

View file

@ -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<MentionSuggestion[]>(() => {
const tasks: TaskWithTeamContext[] = [];
const seenTaskIds = new Set<string>();
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 };
}

View file

@ -415,6 +415,12 @@ export interface TeamSlice {
allow: boolean, allow: boolean,
message?: string message?: string
) => Promise<void>; ) => Promise<void>;
// Messages panel UI state
messagesPanelMode: 'sidebar' | 'inline';
messagesPanelWidth: number;
setMessagesPanelMode: (mode: 'sidebar' | 'inline') => void;
setMessagesPanelWidth: (width: number) => void;
} }
// --- Per-team launch params persistence --- // --- Per-team launch params persistence ---
@ -563,6 +569,12 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
pendingApprovals: [], pendingApprovals: [],
toolApprovalSettings: loadToolApprovalSettings(), 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[]) => { fetchBranches: async (paths: string[]) => {
const results: Record<string, string | null> = {}; const results: Record<string, string | null> = {};
for (const p of paths) { for (const p of paths) {

View file

@ -1,18 +1,34 @@
export interface MentionSuggestion { export interface MentionSuggestion {
/** Unique key (name or draft.id) */ /** Unique key (name or draft.id) */
id: string; id: string;
/** Name to insert: @name */ /** Human-readable primary label (for tasks: short display id without `#`) */
name: string; name: string;
/** Role displayed in suggestion list */ /** Role displayed in suggestion list */
subtitle?: string; subtitle?: string;
/** Color name from TeamColorSet palette */ /** Color name from TeamColorSet palette */
color?: string; color?: string;
/** Suggestion type — 'member' (default), 'team', 'file', or 'folder' */ /** Suggestion type — 'member' (default), 'team', 'file', 'folder', or 'task' */
type?: 'member' | 'team' | 'file' | 'folder'; type?: 'member' | 'team' | 'file' | 'folder' | 'task';
/** Whether the team is currently online (team suggestions only) */ /** Whether the team is currently online (team suggestions only) */
isOnline?: boolean; isOnline?: boolean;
/** Absolute file/folder path (file/folder suggestions only) */ /** Absolute file/folder path (file/folder suggestions only) */
filePath?: string; filePath?: string;
/** Relative display path (file/folder suggestions only) */ /** Relative display path (file/folder suggestions only) */
relativePath?: string; 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;
} }

View file

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

View file

@ -168,16 +168,26 @@ export interface ChipPosition {
height: number; height: number;
} }
/** export interface InlineMatch<T> {
* Calculates screen positions of chip tokens in textarea using the mirror div technique. item: T;
* Creates a temporary mirror div that replicates textarea layout and measures chip spans. start: number;
*/ end: number;
export function calculateChipPositions( token: string;
}
export interface InlineMatchPosition<T> extends InlineMatch<T> {
top: number;
left: number;
width: number;
height: number;
}
export function calculateInlineMatchPositions<T>(
textarea: HTMLTextAreaElement, textarea: HTMLTextAreaElement,
text: string, text: string,
chips: InlineChip[] matches: InlineMatch<T>[]
): ChipPosition[] { ): InlineMatchPosition<T>[] {
if (chips.length === 0) return []; if (matches.length === 0) return [];
const cs = window.getComputedStyle(textarea); const cs = window.getComputedStyle(textarea);
const mirror = document.createElement('div'); const mirror = document.createElement('div');
@ -210,60 +220,77 @@ export function calculateChipPositions(
mirror.style.overflow = 'hidden'; mirror.style.overflow = 'hidden';
mirror.style.height = 'auto'; mirror.style.height = 'auto';
// Build content with chip tokens wrapped in spans const sortedMatches = [...matches].sort((a, b) => a.start - b.start);
const chipSpans = new Map<string, HTMLSpanElement>(); const tokenSpans = new Map<number, HTMLSpanElement>();
const tokenPositions: { chip: InlineChip; token: string; index: number }[] = [];
// 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; let lastEnd = 0;
for (const { chip, token, index } of tokenPositions) { sortedMatches.forEach((match, index) => {
// Text before this chip if (match.start > lastEnd) {
if (index > lastEnd) { mirror.appendChild(document.createTextNode(text.slice(lastEnd, match.start)));
const textNode = document.createTextNode(text.slice(lastEnd, index));
mirror.appendChild(textNode);
} }
// Chip span
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = token; span.textContent = text.slice(match.start, match.end);
mirror.appendChild(span); 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) { if (lastEnd < text.length) {
mirror.appendChild(document.createTextNode(text.slice(lastEnd))); mirror.appendChild(document.createTextNode(text.slice(lastEnd)));
} }
document.body.appendChild(mirror); document.body.appendChild(mirror);
const positions: ChipPosition[] = []; const positions: InlineMatchPosition<T>[] = [];
for (const { chip } of tokenPositions) { sortedMatches.forEach((match, index) => {
const span = chipSpans.get(chip.id); const span = tokenSpans.get(index);
if (!span) continue; if (!span) return;
positions.push({ positions.push({
chip, ...match,
top: span.offsetTop, top: span.offsetTop,
left: span.offsetLeft, left: span.offsetLeft,
width: span.offsetWidth, width: span.offsetWidth,
height: span.offsetHeight, height: span.offsetHeight,
}); });
} });
document.body.removeChild(mirror); document.body.removeChild(mirror);
return positions; 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<InlineChip>[] = [];
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,
}));
}

View file

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

View file

@ -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<string, MentionSuggestion>();
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;
}