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:
parent
c5c41d2a0d
commit
b6ec408451
29 changed files with 2255 additions and 829 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
506
src/renderer/components/team/messages/MessagesPanel.tsx
Normal file
506
src/renderer/components/team/messages/MessagesPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
89
src/renderer/components/ui/TaskReferenceInteractionLayer.tsx
Normal file
89
src/renderer/components/ui/TaskReferenceInteractionLayer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
122
src/renderer/hooks/useResizablePanel.ts
Normal file
122
src/renderer/hooks/useResizablePanel.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
123
src/renderer/hooks/useTaskSuggestions.ts
Normal file
123
src/renderer/hooks/useTaskSuggestions.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
157
src/renderer/utils/bugReportUtils.ts
Normal file
157
src/renderer/utils/bugReportUtils.ts
Normal 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()}`;
|
||||
};
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
27
src/renderer/utils/mentionSuggestions.ts
Normal file
27
src/renderer/utils/mentionSuggestions.ts
Normal 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));
|
||||
}
|
||||
62
src/renderer/utils/taskReferenceUtils.ts
Normal file
62
src/renderer/utils/taskReferenceUtils.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue