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 { useStore } from '@renderer/store';
import { createLogger } from '@shared/utils/logger';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { AlertTriangle, Bug, Check, Copy, RefreshCw } from 'lucide-react';
import {
buildBugReportText,
buildGitHubBugReportUrl,
type BugReportContext,
} from '@renderer/utils/bugReportUtils';
const logger = createLogger('Component:ErrorBoundary');
@ -12,15 +19,19 @@ interface Props {
interface State {
hasError: boolean;
copiedReport: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
private copyResetTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
copiedReport: false,
error: null,
errorInfo: null,
};
@ -40,16 +51,83 @@ export class ErrorBoundary extends Component<Props, State> {
};
handleReset = (): void => {
if (this.copyResetTimeout) {
clearTimeout(this.copyResetTimeout);
this.copyResetTimeout = null;
}
this.setState({
hasError: false,
copiedReport: false,
error: null,
errorInfo: null,
});
};
componentWillUnmount(): void {
if (this.copyResetTimeout) {
clearTimeout(this.copyResetTimeout);
this.copyResetTimeout = null;
}
}
getBugReportContext = (): BugReportContext => {
const state = useStore.getState();
const activeTab = state.getActiveTab();
return {
activeTabType: activeTab?.type ?? null,
activeTabLabel: activeTab?.label ?? null,
activeTeamName: activeTab?.teamName ?? null,
selectedTeamName: state.selectedTeamName,
taskId: state.globalTaskDetail?.taskId ?? state.pendingReviewRequest?.taskId ?? null,
sessionId: activeTab?.sessionId ?? null,
projectId: activeTab?.projectId ?? state.activeProjectId,
};
};
handleCreateGitHubIssue = (): void => {
const issueUrl = buildGitHubBugReportUrl({
error: this.state.error,
componentStack: this.state.errorInfo?.componentStack ?? null,
context: this.getBugReportContext(),
});
if (window.electronAPI?.openExternal) {
void window.electronAPI.openExternal(issueUrl);
return;
}
window.open(issueUrl, '_blank', 'noopener,noreferrer');
};
handleCopyErrorDetails = async (): Promise<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
render(): ReactNode {
const { hasError, error, errorInfo } = this.state;
const { hasError, copiedReport, error, errorInfo } = this.state;
const { children, fallback } = this.props;
if (hasError) {
@ -85,13 +163,31 @@ export class ErrorBoundary extends Component<Props, State> {
</div>
)}
<div className="flex gap-4">
<div className="flex flex-wrap justify-center gap-4">
<button
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"
>
Try Again
</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
onClick={this.handleReload}
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
</button>
</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>
);
}

View file

@ -50,6 +50,8 @@ function getStatusLabel(column: string): string {
interface TaskTooltipProps {
/** Canonical task id or short display id reference. */
taskId: string;
/** Optional owning team for cross-team task references. */
teamName?: string;
/** Rendered trigger element. */
children: React.ReactElement;
/** Tooltip placement. */
@ -62,11 +64,34 @@ interface TaskTooltipProps {
*/
export const TaskTooltip = ({
taskId,
teamName,
children,
side = 'top',
}: TaskTooltipProps): React.JSX.Element => {
const tasks = useStore((s) => s.selectedTeamData?.tasks);
const members = useStore((s) => s.selectedTeamData?.members);
const selectedTeamName = useStore((s) => s.selectedTeamName);
const selectedTeamData = useStore((s) => s.selectedTeamData);
const globalTasks = useStore((s) => s.globalTasks);
const teamByName = useStore((s) => s.teamByName);
const tasks = useMemo(() => {
if (teamName && selectedTeamName === teamName) {
return selectedTeamData?.tasks ?? [];
}
if (teamName) {
return globalTasks.filter((task) => task.teamName === teamName);
}
const currentTasks = selectedTeamData?.tasks ?? [];
const currentMatch = currentTasks.find((task) => taskMatchesRef(task, taskId));
if (currentMatch) return currentTasks;
return globalTasks;
}, [globalTasks, selectedTeamData, selectedTeamName, teamName, taskId]);
const members = useMemo(() => {
if (teamName && selectedTeamName === teamName) {
return selectedTeamData?.members ?? [];
}
return [];
}, [selectedTeamData, selectedTeamName, teamName]);
const task = useMemo(() => tasks?.find((t) => taskMatchesRef(t, taskId)), [tasks, taskId]);
@ -81,11 +106,24 @@ export const TaskTooltip = ({
const column = getEffectiveColumn(task);
const statusColor = STATUS_COLORS[column] ?? STATUS_COLORS.pending;
const label = getStatusLabel(column);
const taskTeamName =
typeof (task as unknown as { teamName?: unknown }).teamName === 'string'
? (task as unknown as { teamName: string }).teamName
: undefined;
const resolvedTeamName = teamName ?? taskTeamName;
const resolvedTeamDisplayName = resolvedTeamName
? teamByName[resolvedTeamName]?.displayName
: null;
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<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 */}
<div className="text-xs font-medium text-[var(--color-text)]">
<span className="text-[var(--color-text-muted)]">{formatTaskDisplayLabel(task)}</span>{' '}
@ -109,8 +147,10 @@ export const TaskTooltip = ({
) : null}
{/* Owner */}
{task.owner ? (
{task.owner && members.length > 0 ? (
<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>
)}

View file

@ -15,37 +15,27 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useBranchSync } from '@renderer/hooks/useBranchSync';
import { useResizablePanel } from '@renderer/hooks/useResizablePanel';
import { useTabUI } from '@renderer/hooks/useTabUI';
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { createChipFromSelection } from '@renderer/utils/chipUtils';
import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies';
import { formatProjectPath } from '@renderer/utils/pathDisplay';
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor';
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import {
AlertTriangle,
Bell,
CheckCheck,
ChevronsDownUp,
ChevronsUpDown,
ChevronRight,
Clock,
Code,
Columns3,
FolderOpen,
GitBranch,
History,
MessageSquare,
Pencil,
Play,
Plus,
@ -59,9 +49,6 @@ import {
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ActiveTasksBlock } from './activity/ActiveTasksBlock';
import { ActivityTimeline } from './activity/ActivityTimeline';
import { PendingRepliesBlock } from './activity/PendingRepliesBlock';
import { AddMemberDialog } from './dialogs/AddMemberDialog';
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
import { EditTeamDialog } from './dialogs/EditTeamDialog';
@ -78,8 +65,7 @@ const ProjectEditorOverlay = lazy(() =>
import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay }))
);
import { MemberList } from './members/MemberList';
import { MessageComposer } from './messages/MessageComposer';
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
import { MessagesPanel } from './messages/MessagesPanel';
import { ChangeReviewDialog } from './review/ChangeReviewDialog';
import { ClaudeLogsSection } from './ClaudeLogsSection';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
@ -90,16 +76,10 @@ import { TeamSessionsSection } from './TeamSessionsSection';
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
import type { KanbanSortState } from './kanban/KanbanSortPopover';
import type { MessagesFilterState } from './messages/MessagesFilterPopover';
import type { ContextInjection } from '@renderer/types/contextInjection';
import type { Session } from '@renderer/types/data';
import type { InlineChip } from '@renderer/types/inlineChip';
import type {
InboxMessage,
MemberSpawnStatusEntry,
ResolvedTeamMember,
TeamTaskWithKanban,
} from '@shared/types';
import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
import type { EditorSelectionAction } from '@shared/types/editor';
interface TeamDetailViewProps {
@ -223,7 +203,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
updateTaskStatus,
updateTaskOwner,
sendTeamMessage,
sendCrossTeamMessage,
requestReview,
createTeamTask,
startTask,
@ -252,6 +231,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
fetchDeletedTasks,
deletedTasks,
launchParams,
messagesPanelMode,
messagesPanelWidth,
setMessagesPanelMode,
setMessagesPanelWidth,
} = useStore(
useShallow((s) => ({
data: s.selectedTeamData,
@ -268,7 +251,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
updateTaskStatus: s.updateTaskStatus,
updateTaskOwner: s.updateTaskOwner,
sendTeamMessage: s.sendTeamMessage,
sendCrossTeamMessage: s.sendCrossTeamMessage,
requestReview: s.requestReview,
createTeamTask: s.createTeamTask,
startTask: s.startTask,
@ -299,6 +281,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
fetchDeletedTasks: s.fetchDeletedTasks,
deletedTasks: s.deletedTasks,
launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined,
messagesPanelMode: s.messagesPanelMode,
messagesPanelWidth: s.messagesPanelWidth,
setMessagesPanelMode: s.setMessagesPanelMode,
setMessagesPanelWidth: s.setMessagesPanelWidth,
}))
);
@ -312,6 +298,20 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
} = useTabUI();
const [isContextButtonHovered, setIsContextButtonHovered] = useState(false);
// Messages panel resize
const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } =
useResizablePanel({
width: messagesPanelWidth,
onWidthChange: setMessagesPanelWidth,
minWidth: 280,
maxWidth: 600,
side: 'left',
});
const toggleMessagesPanelMode = useCallback(() => {
setMessagesPanelMode(messagesPanelMode === 'sidebar' ? 'inline' : 'sidebar');
}, [messagesPanelMode, setMessagesPanelMode]);
useEffect(() => {
if (tabId) {
initTabUIState(tabId);
@ -344,15 +344,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}, [memberSpawnStatuses]);
const [kanbanSearch, setKanbanSearch] = useState('');
const [messagesSearchQuery, setMessagesSearchQuery] = useState('');
const [messagesFilter, setMessagesFilter] = useState<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)
const pendingRevealFile = useStore((s) => s.editorPendingRevealFile);
@ -633,32 +624,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
[data?.members]
);
const filteredMessages = useMemo(() => {
if (!data) return [];
return filterTeamMessages(data.messages, {
timeWindow,
filter: messagesFilter,
searchQuery: messagesSearchQuery,
});
}, [data, timeWindow, messagesFilter, messagesSearchQuery]);
const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName ?? '');
const { expandedSet, toggle: toggleExpandOverride } = useTeamMessagesExpanded(teamName ?? '');
const messagesUnreadCount = useMemo(
() => filteredMessages.filter((m) => !m.read && !readSet.has(toMessageKey(m))).length,
[filteredMessages, readSet]
);
const handleMessageVisible = useCallback(
(message: InboxMessage) => markRead(toMessageKey(message)),
[markRead]
);
const handleMarkAllRead = useCallback(() => {
const keys = filteredMessages
.filter((m) => !m.read && !readSet.has(toMessageKey(m)))
.map((m) => toMessageKey(m));
markAllRead(keys);
}, [filteredMessages, readSet, markAllRead]);
const kanbanDisplayTasks = useMemo(() => {
const query = kanbanSearch.trim();
if (!query) return filteredTasks;
@ -673,50 +638,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
const pendingCrossTeamReplies = useMemo(
() => computePendingCrossTeamReplies(data?.messages ?? []),
[data?.messages]
);
/** Whether the Status block has any visible items (pending replies or active tasks). */
const hasStatusItems = useMemo(() => {
const members = data?.members ?? [];
const tasks = data?.tasks ?? [];
// Check pending replies (mirrors PendingRepliesBlock logic)
const hasPendingReplies = Object.keys(pendingRepliesByMember).some((name) =>
members.some((m) => m.name === name)
);
if (hasPendingReplies) return true;
if (pendingCrossTeamReplies.length > 0) return true;
// Check active tasks (mirrors ActiveTasksBlock logic)
const tMap = new Map(tasks.map((t) => [t.id, t]));
return members.some((m) => {
if (!m.currentTaskId) return false;
const task = tMap.get(m.currentTaskId);
if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false;
return true;
});
}, [data?.members, data?.tasks, pendingRepliesByMember, pendingCrossTeamReplies.length]);
useEffect(() => {
if (!data || Object.keys(pendingRepliesByMember).length === 0) return;
const next = { ...pendingRepliesByMember };
let changed = false;
for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) {
const hasReply = data.messages.some((m) => {
if (m.from !== memberName) return false;
const ts = Date.parse(m.timestamp);
return Number.isFinite(ts) && ts > sentAtMs;
});
if (hasReply) {
delete next[memberName];
changed = true;
}
}
if (changed) setPendingRepliesByMember(next);
}, [data, pendingRepliesByMember]);
const openCreateTaskDialog = (
subject = '',
@ -1007,6 +928,53 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</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
ref={contentRef}
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} />
<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>
}
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
{messagesPanelMode === 'inline' && (
<MessagesPanel
teamName={teamName}
position="inline"
onTogglePosition={toggleMessagesPanelMode}
members={activeMembers}
tasks={data.tasks}
messages={data.messages}
isTeamAlive={data.isAlive}
sending={sendingMessage}
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}
timeWindow={timeWindow}
teamSessionIds={teamSessionIds}
currentLeadSessionId={data?.config.leadSessionId}
pendingRepliesByMember={pendingRepliesByMember}
onPendingReplyChange={setPendingRepliesByMember}
onMemberClick={setSelectedMember}
onTaskClick={setSelectedTask}
onCreateTaskFromMessage={(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) });
setSendDialogOpen(true);
}}
onMessageVisible={handleMessageVisible}
onRestartTeam={() => setLaunchDialogOpen(true)}
onTaskIdClick={(taskId) => {
const task =
@ -1764,7 +1559,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
if (task) setSelectedTask(task);
}}
/>
</CollapsibleTeamSection>
)}
<ReviewDialog
open={requestChangesTaskId !== null}

View file

@ -24,6 +24,7 @@ import {
} from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import {
CROSS_TEAM_SENT_SOURCE,
@ -257,11 +258,6 @@ const AUTH_ERROR_PATTERNS = [
// 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. */
function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
return text.split(/(#[A-Za-z0-9-]+\b)/g).map((part, i) => {
@ -304,7 +300,9 @@ export const ActivityItem = ({
}: ActivityItemProps): React.JSX.Element => {
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
const { isLight } = useTheme();
const formattedRole = formatAgentRole(memberRole);
// Hide role when it matches the sender name (avoids "lead" badge + "Team Lead" text duplication)
const formattedRole =
memberRole && memberRole !== message.from ? formatAgentRole(memberRole) : null;
const teams = useStore((s) => s.teams);
const teamNames = useMemo(
@ -312,9 +310,18 @@ export const ActivityItem = ({
[teams]
);
const timestamp = Number.isNaN(Date.parse(message.timestamp))
? message.timestamp
: new Date(message.timestamp).toLocaleString();
const timestamp = useMemo(() => {
if (Number.isNaN(Date.parse(message.timestamp))) return message.timestamp;
const date = new Date(message.timestamp);
const now = new Date();
const isToday =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
return isToday
? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: date.toLocaleString();
}, [message.timestamp]);
const structured = parseStructuredAgentMessage(message.text);
// Only flag agent messages as rate-limited, not user's own quotes

View file

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

View file

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

View file

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

View file

@ -39,6 +39,7 @@ import {
import { AdvancedCliSection } from './AdvancedCliSection';
import { EffortLevelSelector } from './EffortLevelSelector';
import { OptionalSettingsSection } from './OptionalSettingsSection';
import { ProjectPathSelector } from './ProjectPathSelector';
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
import { CronScheduleInput } from '../schedule/CronScheduleInput';
@ -506,6 +507,32 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return args;
}, [isLaunch, skipPermissions, selectedModel, extendedContext, selectedEffort, clearContext]);
const launchOptionalSummary = useMemo(() => {
if (!isLaunch) return [];
const summary: string[] = [];
if (promptDraft.value.trim()) summary.push('Lead prompt');
if (selectedModel) summary.push(`Model: ${selectedModel}`);
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
if (extendedContext) summary.push('Extended context');
if (skipPermissions) summary.push('Auto-approve tools');
if (clearContext) summary.push('Fresh session');
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
if (customArgs.trim()) summary.push('Custom CLI args');
return summary;
}, [
isLaunch,
promptDraft.value,
selectedModel,
selectedEffort,
extendedContext,
skipPermissions,
clearContext,
worktreeEnabled,
worktreeName,
customArgs,
]);
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
@ -794,7 +821,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
Schedule-only: Schedule configuration section
*/}
{isSchedule ? (
<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
type="button"
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)
*/}
<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
Launch: optional settings
Schedule: prompt + execution defaults
*/}
{isLaunch ? (
<>
<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)
<OptionalSettingsSection
title="Optional launch settings"
description="Keep the launch flow focused on the project path and only expand this when you want extra control."
summary={launchOptionalSummary}
>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="dialog-prompt" className="label-optional">
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="Instructions for team lead..."
footerRight={
promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">
Draft saved
</span>
) : null
}
/>
</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>
<TeamModelSelector
value={selectedModel}
onValueChange={setSelectedModel}
id="dialog-model"
/>
<EffortLevelSelector
value={selectedEffort}
onValueChange={setSelectedEffort}
id="dialog-effort"
/>
<ExtendedContextCheckbox
id="launch-extended-context"
checked={extendedContext}
onCheckedChange={setExtendedContext}
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>
)}
{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>
<AdvancedCliSection
teamName={effectiveTeamName}
internalArgs={internalArgs}
worktreeEnabled={worktreeEnabled}
onWorktreeEnabledChange={setWorktreeEnabled}
worktreeName={worktreeName}
onWorktreeNameChange={setWorktreeName}
customArgs={customArgs}
onCustomArgsChange={setCustomArgs}
/>
<div>
<TeamModelSelector
value={selectedModel}
onValueChange={setSelectedModel}
id="dialog-model"
/>
<EffortLevelSelector
value={selectedEffort}
onValueChange={setSelectedEffort}
id="dialog-effort"
/>
<SkipPermissionsCheckbox
id="dialog-skip-permissions"
checked={skipPermissions}
onCheckedChange={setSkipPermissions}
/>
</div>
</>
) : null}
)}
{/*
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">
<Label>Project</Label>
<div className="space-y-2">
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
<button
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
cwdMode === 'project'
? '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')}
>
From project list
</button>
<button
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
cwdMode === 'custom'
? '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')}
>
Custom path
</button>
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-start">
<div className="inline-flex shrink-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
<button
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
cwdMode === 'project'
? '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')}
>
From project list
</button>
<button
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
cwdMode === 'custom'
? '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')}
>
Custom path
</button>
</div>
{cwdMode === 'project' ? (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
<Combobox
options={projects.map((project) => ({
value: project.path,
label: project.name,
description: project.path,
}))}
value={selectedProjectPath}
onValueChange={onSelectedProjectPathChange}
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
searchPlaceholder="Search project by name or path"
emptyMessage="Nothing found"
disabled={projectsLoading || projects.length === 0}
renderOption={(option, isSelected, query) => (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
<div className="min-w-0 flex-1">
{cwdMode === 'project' ? (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
<div className="min-w-0 flex-1">
<Combobox
options={projects.map((project) => ({
value: project.path,
label: project.name,
description: project.path,
}))}
value={selectedProjectPath}
onValueChange={onSelectedProjectPathChange}
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
searchPlaceholder="Search project by name or path"
emptyMessage="Nothing found"
disabled={projectsLoading || projects.length === 0}
renderOption={(option, isSelected, query) => (
<>
<Check
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">
<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>
{!selectedProjectPath ? (
<p className="text-[11px] text-[var(--color-text-muted)]">
Select a project from the list
</p>
) : null}
{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.
</p>
) : null}
</div>
</div>
{!selectedProjectPath ? (
<p className="text-[11px] text-[var(--color-text-muted)]">
Select a project from the list
</p>
) : null}
{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.
</p>
) : null}
</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 flex-1 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 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}
</div>

View file

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

View file

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

View file

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

View file

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

View file

@ -541,7 +541,10 @@ export const KanbanBoard = ({
</div>
{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) => {
const columnTasks = groupedOrdered.get(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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useComposerDraft } from '@renderer/hooks/useComposerDraft';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
@ -133,6 +134,7 @@ export const MessageComposer = ({
);
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
const trimmed = draft.text.trim();
@ -757,6 +759,7 @@ export const MessageComposer = ({
onValueChange={draft.setText}
suggestions={mentionSuggestions}
teamSuggestions={teamMentionSuggestions}
taskSuggestions={taskSuggestions}
chips={draft.chips}
onChipRemove={draft.removeChip}
projectPath={projectPath}

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

View file

@ -1,12 +1,18 @@
import * as React from 'react';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { PROSE_LINK } from '@renderer/constants/cssVariables';
import { useFileSuggestions } from '@renderer/hooks/useFileSuggestions';
import { useMentionDetection } from '@renderer/hooks/useMentionDetection';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { chipToken } from '@renderer/types/inlineChip';
import {
doesSuggestionMatchQuery,
getSuggestionInsertionText,
} from '@renderer/utils/mentionSuggestions';
import { nameColorSet } from '@renderer/utils/projectColor';
import { findTaskReferenceMatches } from '@renderer/utils/taskReferenceUtils';
import {
createChipFromSelection,
findChipBoundary,
@ -18,6 +24,7 @@ import { AutoResizeTextarea } from './auto-resize-textarea';
import { ChipInteractionLayer } from './ChipInteractionLayer';
import { CodeChipBadge } from './CodeChipBadge';
import { MentionSuggestionList } from './MentionSuggestionList';
import { TaskReferenceInteractionLayer } from './TaskReferenceInteractionLayer';
import type { AutoResizeTextareaProps } from './auto-resize-textarea';
import type { InlineChip } from '@renderer/types/inlineChip';
@ -38,13 +45,19 @@ interface MentionSegment {
suggestion: MentionSuggestion;
}
interface TaskSegment {
type: 'task';
value: string;
suggestion: MentionSuggestion;
}
interface ChipSegment {
type: 'chip';
value: string;
chip: InlineChip;
}
type Segment = TextSegment | MentionSegment | ChipSegment;
type Segment = TextSegment | MentionSegment | TaskSegment | ChipSegment;
// ---------------------------------------------------------------------------
// Mention segment parsing (splits text into plain text + @mention segments)
@ -63,7 +76,9 @@ function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): S
if (!text || suggestions.length === 0) return [{ type: 'text', value: text }];
// Sort by name length descending for greedy matching
const sorted = [...suggestions].sort((a, b) => b.name.length - a.name.length);
const sorted = [...suggestions]
.filter((suggestion) => suggestion.type !== 'task')
.sort((a, b) => b.name.length - a.name.length);
const segments: Segment[] = [];
let i = 0;
@ -86,9 +101,10 @@ function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): S
let matched = false;
for (const suggestion of sorted) {
const end = i + 1 + suggestion.name.length;
const insertionText = getSuggestionInsertionText(suggestion);
const end = i + 1 + insertionText.length;
if (end > text.length) continue;
if (text.slice(i + 1, end).toLowerCase() !== suggestion.name.toLowerCase()) continue;
if (text.slice(i + 1, end).toLowerCase() !== insertionText.toLowerCase()) continue;
// Character after name must be boundary
if (end < text.length) {
@ -119,6 +135,40 @@ function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): S
return segments;
}
function parseSuggestionSegments(
text: string,
mentionSuggestions: MentionSuggestion[],
taskSuggestions: MentionSuggestion[]
): Segment[] {
if (!text) return [{ type: 'text', value: text }];
const taskMatches = findTaskReferenceMatches(text, taskSuggestions);
if (taskMatches.length === 0) {
return parseMentionSegments(text, mentionSuggestions);
}
const segments: Segment[] = [];
let lastEnd = 0;
for (const match of taskMatches) {
if (match.start > lastEnd) {
segments.push(...parseMentionSegments(text.slice(lastEnd, match.start), mentionSuggestions));
}
segments.push({
type: 'task',
value: match.raw,
suggestion: match.suggestion,
});
lastEnd = match.end;
}
if (lastEnd < text.length) {
segments.push(...parseMentionSegments(text.slice(lastEnd), mentionSuggestions));
}
return segments;
}
// ---------------------------------------------------------------------------
// Extended segment parser: chips + mentions
// ---------------------------------------------------------------------------
@ -129,11 +179,12 @@ function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): S
*/
function parseSegments(
text: string,
suggestions: MentionSuggestion[],
mentionSuggestions: MentionSuggestion[],
taskSuggestions: MentionSuggestion[],
chips: InlineChip[]
): Segment[] {
if (!text) return [{ type: 'text', value: text }];
if (chips.length === 0) return parseMentionSegments(text, suggestions);
if (chips.length === 0) return parseSuggestionSegments(text, mentionSuggestions, taskSuggestions);
// Build a map of chip tokens for fast lookup
const chipTokenMap = new Map<string, InlineChip>();
@ -154,7 +205,9 @@ function parseSegments(
}
chipPositions.sort((a, b) => a.start - b.start);
if (chipPositions.length === 0) return parseMentionSegments(text, suggestions);
if (chipPositions.length === 0) {
return parseSuggestionSegments(text, mentionSuggestions, taskSuggestions);
}
const segments: Segment[] = [];
let lastEnd = 0;
@ -163,7 +216,7 @@ function parseSegments(
// Text before this chip → parse for mentions
if (pos.start > lastEnd) {
const fragment = text.slice(lastEnd, pos.start);
segments.push(...parseMentionSegments(fragment, suggestions));
segments.push(...parseSuggestionSegments(fragment, mentionSuggestions, taskSuggestions));
}
segments.push({ type: 'chip', value: pos.token, chip: pos.chip });
lastEnd = pos.end;
@ -171,7 +224,9 @@ function parseSegments(
// Remaining text after last chip → parse for mentions
if (lastEnd < text.length) {
segments.push(...parseMentionSegments(text.slice(lastEnd), suggestions));
segments.push(
...parseSuggestionSegments(text.slice(lastEnd), mentionSuggestions, taskSuggestions)
);
}
return segments;
@ -210,6 +265,8 @@ interface MentionableTextareaProps extends Omit<
onFileChipInsert?: (chip: InlineChip) => void;
/** Team suggestions for cross-team @mentions */
teamSuggestions?: MentionSuggestion[];
/** Task suggestions for #task references */
taskSuggestions?: MentionSuggestion[];
/** Called when Enter (without Shift) is pressed. */
onModEnter?: () => void;
}
@ -230,6 +287,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
projectPath,
onFileChipInsert,
teamSuggestions = [],
taskSuggestions = [],
onModEnter,
style,
className,
@ -244,6 +302,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
// --- File search activation ---
const enableFiles = !!projectPath;
const enableTaskSearch = taskSuggestions.length > 0;
const setRefs = React.useCallback(
(node: HTMLTextAreaElement | null) => {
@ -260,9 +319,10 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
const {
isOpen,
activeTriggerChar,
query,
filteredSuggestions: memberSuggestions,
selectedIndex,
setSelectedIndex,
dropdownPosition,
selectSuggestion,
dismiss,
@ -271,30 +331,46 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
handleChange: mentionHandleChange,
handleSelect: mentionHandleSelect,
} = useMentionDetection({
suggestions,
value,
onValueChange,
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 ---
const { suggestions: fileSuggestions, loading: filesLoading } = useFileSuggestions(
enableFiles ? projectPath : null,
query,
isOpen && enableFiles
activeTriggerChar === '@' ? query : '',
isOpen && enableFiles && activeTriggerChar === '@'
);
const isAtTrigger = activeTriggerChar !== '#';
const memberSuggestions = React.useMemo(() => {
if (!isOpen || !isAtTrigger) return [];
if (!query) return suggestions;
return suggestions.filter((member) => doesSuggestionMatchQuery(member, query));
}, [isAtTrigger, isOpen, query, suggestions]);
// --- Team suggestions filtered by query ---
const filteredTeamSuggestions = React.useMemo(() => {
if (teamSuggestions.length === 0 || !isOpen) return [];
if (teamSuggestions.length === 0 || !isOpen || !isAtTrigger) return [];
if (!query) return teamSuggestions;
const lower = query.toLowerCase();
return teamSuggestions.filter((t) => t.name.toLowerCase().includes(lower));
}, [teamSuggestions, isOpen, query]);
return teamSuggestions.filter((team) => doesSuggestionMatchQuery(team, query));
}, [teamSuggestions, isAtTrigger, isOpen, query]);
const filteredTaskSuggestions = React.useMemo(() => {
if (taskSuggestions.length === 0 || !isOpen || activeTriggerChar !== '#') return [];
if (!query) return taskSuggestions;
return taskSuggestions.filter((task) => doesSuggestionMatchQuery(task, query));
}, [taskSuggestions, activeTriggerChar, isOpen, query]);
// Merged suggestion list: members → online teams → offline teams → files
const allSuggestions = React.useMemo(() => {
const atSuggestions = React.useMemo(() => {
const onlineTeams = filteredTeamSuggestions.filter((t) => t.isOnline);
const offlineTeams = filteredTeamSuggestions.filter((t) => !t.isOnline);
const merged = [...memberSuggestions, ...onlineTeams, ...offlineTeams];
@ -302,21 +378,19 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
if (fileSuggestions.length === 0) return merged;
return [...merged, ...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(() => {
setMergedIndex(0);
}, [query, allSuggestions.length]);
// Use merged index when we have extra suggestion types (teams or files)
const hasMergedSuggestions = enableFiles || teamSuggestions.length > 0;
// Effective index: use merged when extra types present, hook's index otherwise
const effectiveIndex = hasMergedSuggestions ? mergedIndex : selectedIndex;
const effectiveSuggestions = hasMergedSuggestions ? allSuggestions : memberSuggestions;
if (!isOpen) return;
if (effectiveSuggestions.length === 0) {
setSelectedIndex(0);
return;
}
if (selectedIndex >= effectiveSuggestions.length) {
setSelectedIndex(0);
}
}, [effectiveSuggestions.length, isOpen, selectedIndex, setSelectedIndex]);
// --- File selection handler ---
const handleFileSelect = React.useCallback(
@ -436,8 +510,8 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
[getTriggerIndex, query, value, chips, onValueChange, onFileChipInsert, onChipRemove, dismiss]
);
// --- Merged selection handler ---
const handleMergedSelect = React.useCallback(
// --- Active selection handler ---
const handleActiveSelect = React.useCallback(
(s: MentionSuggestion) => {
if (s.type === 'file') {
handleFileSelect(s);
@ -465,17 +539,22 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
}, [value]);
// --- 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
const allOverlaySuggestions = React.useMemo(
const mentionOverlaySuggestions = React.useMemo(
() => (teamSuggestions.length > 0 ? [...suggestions, ...teamSuggestions] : suggestions),
[suggestions, teamSuggestions]
);
const segments = React.useMemo(
() => (hasOverlay ? parseSegments(value, allOverlaySuggestions, chips) : []),
[hasOverlay, value, allOverlaySuggestions, chips]
() =>
hasOverlay ? parseSegments(value, mentionOverlaySuggestions, taskSuggestions, chips) : [],
[hasOverlay, value, mentionOverlaySuggestions, taskSuggestions, chips]
);
// Sync backdrop scroll with textarea scroll + track scrollTop for interaction layer
@ -561,47 +640,15 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
[chips, onChipRemove, value, onValueChange]
);
// --- File-aware keyboard handler (replaces mention handler when files enabled) ---
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
// Composed key handler: suggestion logic first (when open) → Mod+Enter submit → chip logic
const composedHandleKeyDown = React.useCallback(
(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 (hasMergedSuggestions) {
fileMentionHandleKeyDown(e);
} else {
mentionHandleKeyDown(e);
}
mentionHandleKeyDown(e, effectiveSuggestions.length, (index) => {
const next = effectiveSuggestions[index];
if (next) handleActiveSelect(next);
});
if (e.defaultPrevented) return;
}
// Enter (without Shift) → submit; Shift+Enter → newline
@ -611,22 +658,15 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
return;
}
handleChipKeyDown(e);
if (!e.defaultPrevented && !isOpen) {
if (hasMergedSuggestions) {
fileMentionHandleKeyDown(e);
} else {
mentionHandleKeyDown(e);
}
}
},
[
onModEnter,
handleChipKeyDown,
hasMergedSuggestions,
fileMentionHandleKeyDown,
mentionHandleKeyDown,
isOpen,
effectiveSuggestions.length,
effectiveSuggestions,
handleActiveSelect,
]
);
@ -707,7 +747,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
// --- Rotating tips ---
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: 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 showHintRow =
showHint && (suggestions.length > 0 || enableFiles || teamSuggestions.length > 0);
showHint &&
(suggestions.length > 0 || enableFiles || teamSuggestions.length > 0 || enableTaskSearch);
const showFooter = showHintRow || footerRight;
return (
@ -759,6 +800,17 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
if (seg.type === 'chip') {
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)
const isTeamMention = seg.suggestion.type === 'team';
const colorSet = seg.suggestion.color
@ -785,6 +837,15 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
</div>
) : null}
{taskSuggestions.length > 0 ? (
<TaskReferenceInteractionLayer
taskSuggestions={taskSuggestions}
value={value}
textareaRef={internalRef}
scrollTop={scrollTop}
/>
) : null}
<AutoResizeTextarea
ref={setRefs}
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` }}>
<MentionSuggestionList
suggestions={effectiveSuggestions}
selectedIndex={effectiveIndex}
onSelect={hasMergedSuggestions ? handleMergedSelect : selectSuggestion}
selectedIndex={selectedIndex}
onSelect={handleActiveSelect}
query={query}
hasFileSearch={enableFiles}
filesLoading={enableFiles && filesLoading}
filesLoading={enableFiles && filesLoading && activeTriggerChar === '@'}
/>
</div>
) : 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';
interface UseMentionDetectionOptions {
suggestions: MentionSuggestion[];
value: string;
onValueChange: (v: string) => void;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
/** When true, detect @-trigger even if suggestions list is empty (e.g. for file-only search) */
enableTriggerAlways?: boolean;
/** Supported trigger characters, e.g. ['@', '#'] */
triggerChars?: string[];
/** Enable or disable individual triggers dynamically. */
isTriggerEnabled?: (triggerChar: string) => boolean;
}
export interface DropdownPosition {
@ -18,13 +21,18 @@ export interface DropdownPosition {
interface UseMentionDetectionResult {
isOpen: boolean;
activeTriggerChar: string | null;
query: string;
filteredSuggestions: MentionSuggestion[];
selectedIndex: number;
setSelectedIndex: Dispatch<SetStateAction<number>>;
dropdownPosition: DropdownPosition | null;
selectSuggestion: (s: MentionSuggestion) => 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;
handleSelect: (e: React.SyntheticEvent<HTMLTextAreaElement>) => void;
/** Getter for trigger index — use at call time to avoid stale closure (returns -1 if no active trigger) */
@ -33,6 +41,7 @@ interface UseMentionDetectionResult {
interface MentionTrigger {
triggerIndex: number;
triggerChar: string;
query: string;
}
@ -117,27 +126,32 @@ export function getCaretCoordinates(
}
/**
* Scans backwards from cursor position to find an @ trigger.
* Scans backwards from cursor position to find an active trigger.
* Returns null if no valid trigger found.
*
* Rules:
* - @ must be at start of text or preceded by whitespace
* - Text between @ and cursor must not contain spaces
* - trigger must be at start of text or preceded by whitespace
* - Text between trigger and cursor must not contain spaces
*/
export function findMentionTrigger(text: string, cursorPos: number): MentionTrigger | null {
export function findMentionTrigger(
text: string,
cursorPos: number,
triggerChars: string[] = ['@']
): MentionTrigger | null {
if (cursorPos <= 0) return null;
const beforeCursor = text.slice(0, cursorPos);
const allowedTriggerChars = new Set(triggerChars);
// Scan backwards to find @
for (let i = beforeCursor.length - 1; i >= 0; i--) {
const char = beforeCursor[i];
// If we hit whitespace or newline before finding @, no valid trigger
// If we hit whitespace or newline before finding a trigger, no valid trigger
if (char === ' ' || char === '\t' || char === '\n' || char === '\r') return null;
if (char === '@') {
// @ must be at start or after whitespace/newline
if (allowedTriggerChars.has(char)) {
// trigger must be at start or after whitespace/newline
if (i > 0) {
const preceding = beforeCursor[i - 1];
if (preceding !== ' ' && preceding !== '\t' && preceding !== '\n' && preceding !== '\r') {
@ -146,7 +160,7 @@ export function findMentionTrigger(text: string, cursorPos: number): MentionTrig
}
const query = beforeCursor.slice(i + 1);
return { triggerIndex: i, query };
return { triggerIndex: i, triggerChar: char, query };
}
}
@ -154,34 +168,31 @@ export function findMentionTrigger(text: string, cursorPos: number): MentionTrig
}
export function useMentionDetection({
suggestions,
value,
onValueChange,
textareaRef,
enableTriggerAlways,
triggerChars = ['@'],
isTriggerEnabled,
}: UseMentionDetectionOptions): UseMentionDetectionResult {
const [isOpen, setIsOpen] = useState(false);
const [activeTriggerChar, setActiveTriggerChar] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
const triggerIndexRef = useRef<number>(-1);
const activeTriggerCharRef = useRef<string | null>(null);
// Track current query in a ref so detectTrigger can avoid resetting selectedIndex
// on redundant selectionchange events (e.g. after ArrowDown/Up keyboard navigation)
const queryRef = useRef('');
const filteredSuggestions = useMemo(() => {
if (!isOpen) return [];
if (!query) return suggestions;
const lower = query.toLowerCase();
return suggestions.filter((s) => s.name.toLowerCase().includes(lower));
}, [isOpen, query, suggestions]);
const dismiss = useCallback(() => {
setIsOpen(false);
setActiveTriggerChar(null);
setQuery('');
setSelectedIndex(0);
setDropdownPosition(null);
triggerIndexRef.current = -1;
activeTriggerCharRef.current = null;
queryRef.current = '';
}, []);
@ -201,11 +212,12 @@ export function useMentionDetection({
const selectSuggestion = useCallback(
(s: MentionSuggestion) => {
const textarea = textareaRef.current;
if (!textarea || triggerIndexRef.current < 0) return;
const triggerChar = activeTriggerCharRef.current;
if (!textarea || triggerIndexRef.current < 0 || !triggerChar) return;
const before = value.slice(0, triggerIndexRef.current);
const after = value.slice(triggerIndexRef.current + 1 + query.length);
const insertion = `@${s.name} `;
const after = value.slice(triggerIndexRef.current + 1 + queryRef.current.length);
const insertion = `${triggerChar}${getSuggestionInsertionText(s)} `;
const newValue = before + insertion + after;
const newCursorPos = before.length + insertion.length;
@ -218,11 +230,11 @@ export function useMentionDetection({
textarea.selectionEnd = newCursorPos;
});
},
[value, query, onValueChange, textareaRef, dismiss]
[value, onValueChange, textareaRef, dismiss]
);
/**
* Detects whether cursor is inside an @-trigger region and opens/dismisses the dropdown.
* Detects whether cursor is inside a trigger region and opens/dismisses the dropdown.
*
* Called from handleSelect (selectionchange) must NOT reset selectedIndex when
* the trigger is already active with the same query, otherwise ArrowDown/Up navigation
@ -230,12 +242,17 @@ export function useMentionDetection({
*/
const detectTrigger = useCallback(
(cursorPos: number) => {
const trigger = findMentionTrigger(value, cursorPos);
if (trigger && (suggestions.length > 0 || enableTriggerAlways)) {
const trigger = findMentionTrigger(value, cursorPos, triggerChars);
const isEnabled = trigger ? (isTriggerEnabled?.(trigger.triggerChar) ?? true) : false;
if (trigger && isEnabled) {
const sameQuery =
triggerIndexRef.current === trigger.triggerIndex && queryRef.current === trigger.query;
triggerIndexRef.current === trigger.triggerIndex &&
activeTriggerCharRef.current === trigger.triggerChar &&
queryRef.current === trigger.query;
triggerIndexRef.current = trigger.triggerIndex;
activeTriggerCharRef.current = trigger.triggerChar;
queryRef.current = trigger.query;
setActiveTriggerChar(trigger.triggerChar);
setQuery(trigger.query);
setIsOpen(true);
// Only reset selection when trigger/query actually changed —
@ -248,7 +265,7 @@ export function useMentionDetection({
dismiss();
}
},
[value, suggestions.length, enableTriggerAlways, dismiss, computeDropdownPosition]
[value, triggerChars, isTriggerEnabled, dismiss, computeDropdownPosition]
);
const handleChange = useCallback(
@ -258,10 +275,13 @@ export function useMentionDetection({
// Detect trigger based on cursor position after the change
const cursorPos = e.target.selectionStart;
const trigger = findMentionTrigger(newValue, cursorPos);
if (trigger && (suggestions.length > 0 || enableTriggerAlways)) {
const trigger = findMentionTrigger(newValue, cursorPos, triggerChars);
const isEnabled = trigger ? (isTriggerEnabled?.(trigger.triggerChar) ?? true) : false;
if (trigger && isEnabled) {
triggerIndexRef.current = trigger.triggerIndex;
activeTriggerCharRef.current = trigger.triggerChar;
queryRef.current = trigger.query;
setActiveTriggerChar(trigger.triggerChar);
setQuery(trigger.query);
setIsOpen(true);
// Text changed — always reset selection to first item
@ -271,7 +291,7 @@ export function useMentionDetection({
dismiss();
}
},
[onValueChange, suggestions.length, enableTriggerAlways, dismiss, computeDropdownPosition]
[onValueChange, triggerChars, isTriggerEnabled, dismiss, computeDropdownPosition]
);
const handleSelect = useCallback(
@ -283,24 +303,26 @@ export function useMentionDetection({
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<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) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % filteredSuggestions.length);
setSelectedIndex((prev) => (prev + 1) % suggestionCount);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(
(prev) => (prev - 1 + filteredSuggestions.length) % filteredSuggestions.length
);
setSelectedIndex((prev) => (prev - 1 + suggestionCount) % suggestionCount);
break;
case 'Enter':
if (!e.shiftKey) {
e.preventDefault();
selectSuggestion(filteredSuggestions[selectedIndex]);
onSelectSuggestion(selectedIndex);
}
break;
case 'Escape':
@ -309,16 +331,17 @@ export function useMentionDetection({
break;
}
},
[isOpen, filteredSuggestions, selectedIndex, selectSuggestion, dismiss]
[isOpen, selectedIndex, dismiss]
);
const getTriggerIndex = useCallback(() => triggerIndexRef.current, []);
return {
isOpen,
activeTriggerChar,
query,
filteredSuggestions,
selectedIndex,
setSelectedIndex,
dropdownPosition,
selectSuggestion,
dismiss,

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

View file

@ -1,18 +1,34 @@
export interface MentionSuggestion {
/** Unique key (name or draft.id) */
id: string;
/** Name to insert: @name */
/** Human-readable primary label (for tasks: short display id without `#`) */
name: string;
/** Role displayed in suggestion list */
subtitle?: string;
/** Color name from TeamColorSet palette */
color?: string;
/** Suggestion type — 'member' (default), 'team', 'file', or 'folder' */
type?: 'member' | 'team' | 'file' | 'folder';
/** Suggestion type — 'member' (default), 'team', 'file', 'folder', or 'task' */
type?: 'member' | 'team' | 'file' | 'folder' | 'task';
/** Whether the team is currently online (team suggestions only) */
isOnline?: boolean;
/** Absolute file/folder path (file/folder suggestions only) */
filePath?: string;
/** Relative display path (file/folder suggestions only) */
relativePath?: string;
/** Optional exact text inserted after the trigger (defaults to `name`) */
insertText?: string;
/** Optional extra searchable text (subject, team name, path, etc.) */
searchText?: string;
/** Canonical task id (task suggestions only) */
taskId?: string;
/** Owning team name (task suggestions only) */
teamName?: string;
/** Owning team display name (task suggestions only) */
teamDisplayName?: string;
/** Whether the task belongs to the currently active team */
isCurrentTeamTask?: boolean;
/** Owning task owner name (task suggestions only) */
ownerName?: string;
/** Owning task owner color (task suggestions only) */
ownerColor?: string;
}

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