feat: enhance UI components with dynamic theming and improved task handling
- Updated MemberBadge component to support an additional 'xs' size variant for better responsiveness. - Refactored TaskTooltip to improve task retrieval logic, ensuring accurate task matching based on team context. - Enhanced CreateTeamDialog and LaunchTeamDialog with dynamic background color adjustments based on theme. - Improved OptionalSettingsSection to utilize theme-aware styles for better visual consistency. - Updated MemberDraftRow to apply theme-based background and shadow effects for improved aesthetics. - Enhanced MentionSuggestionList to include dynamic theming for task owner display and team colors. - Refactored task reference utilities to improve task suggestion resolution and boundary handling.
This commit is contained in:
parent
b6ec408451
commit
057591060a
11 changed files with 198 additions and 48 deletions
|
|
@ -13,7 +13,7 @@ interface MemberBadgeProps {
|
||||||
name: string;
|
name: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
/** Avatar + badge size variant */
|
/** Avatar + badge size variant */
|
||||||
size?: 'sm' | 'md';
|
size?: 'xs' | 'sm' | 'md';
|
||||||
/** Hide the avatar icon, show only the name badge */
|
/** Hide the avatar icon, show only the name badge */
|
||||||
hideAvatar?: boolean;
|
hideAvatar?: boolean;
|
||||||
onClick?: (name: string) => void;
|
onClick?: (name: string) => void;
|
||||||
|
|
@ -37,9 +37,10 @@ export const MemberBadge = ({
|
||||||
}: MemberBadgeProps): React.JSX.Element => {
|
}: MemberBadgeProps): React.JSX.Element => {
|
||||||
const colors = getTeamColorSet(color ?? '');
|
const colors = getTeamColorSet(color ?? '');
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const avatarSize = size === 'md' ? 32 : 24;
|
const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18;
|
||||||
const avatarClass = size === 'md' ? 'size-6' : 'size-5';
|
const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4';
|
||||||
const textClass = size === 'md' ? 'text-xs' : 'text-[10px]';
|
const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]';
|
||||||
|
const paddingClass = size === 'xs' ? 'px-1 py-0.5' : 'px-1.5 py-0.5';
|
||||||
|
|
||||||
const badgeStyle = {
|
const badgeStyle = {
|
||||||
backgroundColor: getThemedBadge(colors, isLight),
|
backgroundColor: getThemedBadge(colors, isLight),
|
||||||
|
|
@ -58,7 +59,7 @@ export const MemberBadge = ({
|
||||||
|
|
||||||
const badge = (
|
const badge = (
|
||||||
<span
|
<span
|
||||||
className={`rounded px-1.5 py-0.5 ${textClass} font-medium tracking-wide`}
|
className={`rounded ${paddingClass} ${textClass} font-medium tracking-wide`}
|
||||||
style={badgeStyle}
|
style={badgeStyle}
|
||||||
>
|
>
|
||||||
{name === 'team-lead' ? 'lead' : name}
|
{name === 'team-lead' ? 'lead' : name}
|
||||||
|
|
|
||||||
|
|
@ -73,27 +73,39 @@ export const TaskTooltip = ({
|
||||||
const globalTasks = useStore((s) => s.globalTasks);
|
const globalTasks = useStore((s) => s.globalTasks);
|
||||||
const teamByName = useStore((s) => s.teamByName);
|
const teamByName = useStore((s) => s.teamByName);
|
||||||
|
|
||||||
const tasks = useMemo(() => {
|
const task = useMemo(() => {
|
||||||
if (teamName && selectedTeamName === teamName) {
|
if (teamName && selectedTeamName === teamName) {
|
||||||
return selectedTeamData?.tasks ?? [];
|
return (
|
||||||
|
(selectedTeamData?.tasks ?? []).find((candidate) => taskMatchesRef(candidate, taskId)) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (teamName) {
|
if (teamName) {
|
||||||
return globalTasks.filter((task) => task.teamName === teamName);
|
return (
|
||||||
|
globalTasks.find(
|
||||||
|
(candidate) => candidate.teamName === teamName && taskMatchesRef(candidate, taskId)
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTasks = selectedTeamData?.tasks ?? [];
|
const currentTasks = selectedTeamData?.tasks ?? [];
|
||||||
const currentMatch = currentTasks.find((task) => taskMatchesRef(task, taskId));
|
const currentMatch = currentTasks.find((task) => taskMatchesRef(task, taskId));
|
||||||
if (currentMatch) return currentTasks;
|
if (currentMatch) return currentMatch;
|
||||||
return globalTasks;
|
|
||||||
|
const globalMatches = globalTasks.filter((candidate) => taskMatchesRef(candidate, taskId));
|
||||||
|
return globalMatches.length === 1 ? globalMatches[0] : null;
|
||||||
}, [globalTasks, selectedTeamData, selectedTeamName, teamName, taskId]);
|
}, [globalTasks, selectedTeamData, selectedTeamName, teamName, taskId]);
|
||||||
|
|
||||||
const members = useMemo(() => {
|
const members = useMemo(() => {
|
||||||
if (teamName && selectedTeamName === teamName) {
|
if (teamName && selectedTeamName === teamName) {
|
||||||
return selectedTeamData?.members ?? [];
|
return selectedTeamData?.members ?? [];
|
||||||
}
|
}
|
||||||
|
if (!teamName && task && selectedTeamName === (task as { teamName?: string }).teamName) {
|
||||||
|
return selectedTeamData?.members ?? [];
|
||||||
|
}
|
||||||
return [];
|
return [];
|
||||||
}, [selectedTeamData, selectedTeamName, teamName]);
|
}, [selectedTeamData, selectedTeamName, teamName, task]);
|
||||||
|
|
||||||
const task = useMemo(() => tasks?.find((t) => taskMatchesRef(t, taskId)), [tasks, taskId]);
|
|
||||||
|
|
||||||
const colorMap = useMemo(
|
const colorMap = useMemo(
|
||||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||||
|
|
|
||||||
|
|
@ -839,18 +839,33 @@ export const CreateTeamDialog = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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="rounded-lg border border-[var(--color-border-emphasis)] p-4 shadow-sm md:col-span-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isLight
|
||||||
|
? 'color-mix(in srgb, var(--color-surface-overlay) 24%, white 76%)'
|
||||||
|
: 'var(--color-surface-overlay)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="launch-team"
|
id="launch-team"
|
||||||
|
className="mt-1 shrink-0"
|
||||||
checked={launchTeam}
|
checked={launchTeam}
|
||||||
onCheckedChange={(checked) => setLaunchTeam(checked === true)}
|
onCheckedChange={(checked) => setLaunchTeam(checked === true)}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="launch-team" className="cursor-pointer text-sm font-medium">
|
<Label htmlFor="launch-team" className="cursor-pointer text-sm font-semibold">
|
||||||
Run command after create
|
Run command after create
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
<p
|
||||||
|
className="text-xs"
|
||||||
|
style={{
|
||||||
|
color: isLight
|
||||||
|
? 'color-mix(in srgb, var(--color-text-muted) 54%, var(--color-text) 46%)'
|
||||||
|
: 'var(--color-text-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
Start the team immediately via local Claude CLI.
|
Start the team immediately via local Claude CLI.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea
|
||||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||||
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
|
@ -108,6 +109,7 @@ function getLocalTimezone(): string {
|
||||||
|
|
||||||
export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Element => {
|
export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Element => {
|
||||||
const { open, onClose } = props;
|
const { open, onClose } = props;
|
||||||
|
const { isLight } = useTheme();
|
||||||
const isLaunch = props.mode === 'launch';
|
const isLaunch = props.mode === 'launch';
|
||||||
const isSchedule = props.mode === 'schedule';
|
const isSchedule = props.mode === 'schedule';
|
||||||
const schedule = isSchedule ? ((props as LaunchDialogScheduleMode).schedule ?? null) : null;
|
const schedule = isSchedule ? ((props as LaunchDialogScheduleMode).schedule ?? null) : null;
|
||||||
|
|
@ -821,7 +823,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
Schedule-only: Schedule configuration section
|
Schedule-only: Schedule configuration section
|
||||||
═══════════════════════════════════════════════════════════════════ */}
|
═══════════════════════════════════════════════════════════════════ */}
|
||||||
{isSchedule ? (
|
{isSchedule ? (
|
||||||
<div className="rounded-lg border border-[var(--color-border-emphasis)] bg-[var(--color-surface-overlay)] shadow-sm">
|
<div
|
||||||
|
className="rounded-lg border border-[var(--color-border-emphasis)] shadow-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isLight
|
||||||
|
? 'color-mix(in srgb, var(--color-surface-overlay) 24%, white 76%)'
|
||||||
|
: 'var(--color-surface-overlay)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-1.5 px-3 py-2 text-left"
|
className="flex w-full items-center gap-1.5 px-3 py-2 text-left"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { ChevronRight, Settings2 } from 'lucide-react';
|
import { ChevronRight, Settings2 } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -21,6 +22,7 @@ export const OptionalSettingsSection = ({
|
||||||
children,
|
children,
|
||||||
}: OptionalSettingsSectionProps): React.JSX.Element => {
|
}: OptionalSettingsSectionProps): React.JSX.Element => {
|
||||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||||
|
const { isLight } = useTheme();
|
||||||
|
|
||||||
const visibleSummary = useMemo(
|
const visibleSummary = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -31,6 +33,26 @@ export const OptionalSettingsSection = ({
|
||||||
[summary]
|
[summary]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const containerBackground = isLight
|
||||||
|
? 'color-mix(in srgb, var(--color-surface-overlay) 30%, white 70%)'
|
||||||
|
: 'var(--color-surface-overlay)';
|
||||||
|
|
||||||
|
const contentBackground = isLight
|
||||||
|
? 'color-mix(in srgb, var(--color-surface-overlay) 52%, white 48%)'
|
||||||
|
: 'color-mix(in srgb, var(--color-surface-raised) 88%, black 12%)';
|
||||||
|
|
||||||
|
const headerTitleColor = isLight
|
||||||
|
? 'var(--color-text)'
|
||||||
|
: 'color-mix(in srgb, var(--color-text) 82%, white 18%)';
|
||||||
|
|
||||||
|
const headerMutedColor = isLight
|
||||||
|
? 'color-mix(in srgb, var(--color-text-muted) 58%, var(--color-text) 42%)'
|
||||||
|
: 'color-mix(in srgb, var(--color-text-muted) 52%, white 48%)';
|
||||||
|
|
||||||
|
const headerIconColor = isLight
|
||||||
|
? 'color-mix(in srgb, var(--color-text-muted) 64%, var(--color-text) 36%)'
|
||||||
|
: 'color-mix(in srgb, var(--color-text-muted) 54%, white 46%)';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -38,7 +60,7 @@ export const OptionalSettingsSection = ({
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'color-mix(in srgb, var(--color-surface-overlay) 94%, white 6%)',
|
backgroundColor: containerBackground,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|
@ -48,19 +70,29 @@ export const OptionalSettingsSection = ({
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-start gap-2.5">
|
<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)]">
|
<div
|
||||||
|
className="mt-0.5 rounded-md border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] p-1.5"
|
||||||
|
style={{ color: headerIconColor }}
|
||||||
|
>
|
||||||
<Settings2 className="size-3.5" />
|
<Settings2 className="size-3.5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm font-medium text-[var(--color-text)]">{title}</span>
|
<span className="text-sm font-medium" style={{ color: headerTitleColor }}>
|
||||||
<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)]">
|
{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"
|
||||||
|
style={{ color: headerMutedColor }}
|
||||||
|
>
|
||||||
Optional
|
Optional
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-[var(--color-text-muted)]">{description}</p>
|
<p className="mt-1 text-xs" style={{ color: headerMutedColor }}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
{!isOpen ? (
|
{!isOpen ? (
|
||||||
<p className="mt-1.5 line-clamp-2 text-[11px] text-[var(--color-text-secondary)]">
|
<p className="mt-1.5 line-clamp-2 text-[11px]" style={{ color: headerMutedColor }}>
|
||||||
{visibleSummary.length > 0
|
{visibleSummary.length > 0
|
||||||
? visibleSummary.join(' • ')
|
? visibleSummary.join(' • ')
|
||||||
: 'Collapsed by default to keep the primary flow focused.'}
|
: 'Collapsed by default to keep the primary flow focused.'}
|
||||||
|
|
@ -70,9 +102,10 @@ export const OptionalSettingsSection = ({
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={cn(
|
className={cn(
|
||||||
'mt-0.5 size-4 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150',
|
'mt-0.5 size-4 shrink-0 transition-transform duration-150',
|
||||||
isOpen && 'rotate-90'
|
isOpen && 'rotate-90'
|
||||||
)}
|
)}
|
||||||
|
style={{ color: headerIconColor }}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -80,7 +113,7 @@ export const OptionalSettingsSection = ({
|
||||||
<div
|
<div
|
||||||
className="border-t border-[var(--color-border-emphasis)] px-3 pb-3 pt-2.5"
|
className="border-t border-[var(--color-border-emphasis)] px-3 pb-3 pt-2.5"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'color-mix(in srgb, var(--color-surface-overlay) 86%, white 14%)',
|
backgroundColor: contentBackground,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||||
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
import { ChevronDown, ChevronRight, Info } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||||
|
|
@ -47,6 +48,7 @@ export const MemberDraftRow = ({
|
||||||
projectPath,
|
projectPath,
|
||||||
mentionSuggestions = [],
|
mentionSuggestions = [],
|
||||||
}: MemberDraftRowProps): React.JSX.Element => {
|
}: MemberDraftRowProps): React.JSX.Element => {
|
||||||
|
const { isLight } = useTheme();
|
||||||
const memberColorSet = getTeamColorSet(
|
const memberColorSet = getTeamColorSet(
|
||||||
getMemberColorByName(member.name.trim() || `member-${index}`)
|
getMemberColorByName(member.name.trim() || `member-${index}`)
|
||||||
);
|
);
|
||||||
|
|
@ -117,12 +119,19 @@ export const MemberDraftRow = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-1 gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2 md:grid-cols-[1fr_220px_auto]"
|
className="relative grid grid-cols-1 gap-2 overflow-hidden rounded-md p-2 shadow-sm md:grid-cols-[1fr_220px_auto]"
|
||||||
style={{
|
style={{
|
||||||
borderLeftWidth: '3px',
|
backgroundColor: isLight
|
||||||
borderLeftColor: memberColorSet.border,
|
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
|
||||||
|
: 'var(--color-surface-raised)',
|
||||||
|
boxShadow: isLight ? '0 1px 2px rgba(15, 23, 42, 0.06)' : '0 1px 2px rgba(0, 0, 0, 0.28)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 w-1"
|
||||||
|
style={{ backgroundColor: memberColorSet.border }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Input
|
<Input
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,7 @@ export const ChipInteractionLayer = ({
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute -right-1 -top-1.5 z-30 flex size-3.5 items-center justify-center rounded-full border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] opacity-0 transition-opacity group-hover:opacity-100"
|
className="pointer-events-none absolute -right-1 -top-1.5 z-30 flex size-3.5 items-center justify-center rounded-full border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedText } from '@renderer/constants/teamColors';
|
||||||
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import { Folder, Hash, Loader2, UsersRound } from 'lucide-react';
|
import { Folder, Hash, Loader2, UsersRound } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -56,6 +58,7 @@ export const MentionSuggestionList = ({
|
||||||
filesLoading,
|
filesLoading,
|
||||||
}: MentionSuggestionListProps): React.JSX.Element => {
|
}: MentionSuggestionListProps): React.JSX.Element => {
|
||||||
const listRef = useRef<HTMLUListElement>(null);
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
const { isLight } = useTheme();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const list = listRef.current;
|
const list = listRef.current;
|
||||||
|
|
@ -111,6 +114,12 @@ export const MentionSuggestionList = ({
|
||||||
const isFileOrFolder = isFile || isFolder;
|
const isFileOrFolder = isFile || isFolder;
|
||||||
const isTeam = section === 'team';
|
const isTeam = section === 'team';
|
||||||
const isTask = section === 'task';
|
const isTask = section === 'task';
|
||||||
|
const taskTeamColorSet =
|
||||||
|
isTask && s.color
|
||||||
|
? getTeamColorSet(s.color)
|
||||||
|
: isTask && s.teamDisplayName
|
||||||
|
? nameColorSet(s.teamDisplayName, isLight)
|
||||||
|
: null;
|
||||||
|
|
||||||
// Insert section header on transition
|
// Insert section header on transition
|
||||||
if (showSections && section !== currentSection) {
|
if (showSections && section !== currentSection) {
|
||||||
|
|
@ -177,8 +186,16 @@ export const MentionSuggestionList = ({
|
||||||
>
|
>
|
||||||
<HighlightedName name={isTask ? `#${s.name}` : s.name} query={query} />
|
<HighlightedName name={isTask ? `#${s.name}` : s.name} query={query} />
|
||||||
</span>
|
</span>
|
||||||
{isTask && !s.isCurrentTeamTask && s.teamDisplayName ? (
|
{isTask && s.ownerName ? (
|
||||||
<span className="truncate text-[10px] text-[var(--color-text-muted)]">
|
<MemberBadge name={s.ownerName} color={s.ownerColor} size="xs" disableHoverCard />
|
||||||
|
) : null}
|
||||||
|
{isTask && s.teamDisplayName ? (
|
||||||
|
<span
|
||||||
|
className="truncate text-[10px]"
|
||||||
|
style={
|
||||||
|
taskTeamColorSet ? { color: getThemedText(taskTeamColorSet, isLight) } : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{s.teamDisplayName}
|
{s.teamDisplayName}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -868,6 +868,18 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Gradient fade overlay before corner action buttons */}
|
||||||
|
{cornerAction || cornerActionLeft ? (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-x-0 bottom-0 z-[15] rounded-b-md"
|
||||||
|
style={{
|
||||||
|
height: 48,
|
||||||
|
background:
|
||||||
|
'linear-gradient(to bottom, transparent 0%, var(--color-surface-raised) 75%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{cornerAction ? (
|
{cornerAction ? (
|
||||||
<div className="pointer-events-none absolute bottom-2 right-2 z-20 flex items-end justify-end">
|
<div className="pointer-events-none absolute bottom-2 right-2 z-20 flex items-end justify-end">
|
||||||
<div className="pointer-events-auto">{cornerAction}</div>
|
<div className="pointer-events-auto">{cornerAction}</div>
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,19 @@ export const TaskReferenceInteractionLayer = ({
|
||||||
width: position.width,
|
width: position.width,
|
||||||
height: position.height,
|
height: position.height,
|
||||||
}}
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.metaKey || e.ctrlKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
textarea.focus();
|
||||||
|
const clickOffsetX = e.clientX - e.currentTarget.getBoundingClientRect().left;
|
||||||
|
const snapTo = clickOffsetX < position.width / 2 ? position.start : position.end;
|
||||||
|
textarea.setSelectionRange(snapTo, snapTo);
|
||||||
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
if (!e.metaKey && !e.ctrlKey) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (teamName) {
|
if (teamName) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,43 @@ import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
|
||||||
const TASK_REF_REGEX = /#([A-Za-z0-9-]+)\b/g;
|
const TASK_REF_REGEX = /#([A-Za-z0-9-]+)\b/g;
|
||||||
|
|
||||||
|
function isAllowedTaskRefBoundary(char: string | undefined): boolean {
|
||||||
|
if (!char) return true;
|
||||||
|
return !/[A-Za-z0-9_]/.test(char);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSuggestionsByRef(
|
||||||
|
taskSuggestions: MentionSuggestion[]
|
||||||
|
): Map<string, MentionSuggestion[]> {
|
||||||
|
const suggestionsByRef = new Map<string, MentionSuggestion[]>();
|
||||||
|
|
||||||
|
for (const suggestion of taskSuggestions) {
|
||||||
|
if (suggestion.type !== 'task') continue;
|
||||||
|
const ref = getSuggestionInsertionText(suggestion).trim().toLowerCase();
|
||||||
|
if (!ref) continue;
|
||||||
|
|
||||||
|
const existing = suggestionsByRef.get(ref);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(suggestion);
|
||||||
|
} else {
|
||||||
|
suggestionsByRef.set(ref, [suggestion]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestionsByRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTaskSuggestion(candidates: MentionSuggestion[]): MentionSuggestion | null {
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
|
||||||
|
const currentTeamCandidate = candidates.find((candidate) => candidate.isCurrentTeamTask);
|
||||||
|
if (currentTeamCandidate) return currentTeamCandidate;
|
||||||
|
|
||||||
|
if (candidates.length === 1) return candidates[0];
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskReferenceMatch {
|
export interface TaskReferenceMatch {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
|
|
@ -13,7 +50,10 @@ export interface TaskReferenceMatch {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function linkifyTaskIdsInMarkdown(text: string): string {
|
export function linkifyTaskIdsInMarkdown(text: string): string {
|
||||||
return text.replace(TASK_REF_REGEX, '[#$1](task://$1)');
|
return text.replace(TASK_REF_REGEX, (raw, ref: string, offset: number) => {
|
||||||
|
const preceding = offset > 0 ? text[offset - 1] : undefined;
|
||||||
|
return isAllowedTaskRefBoundary(preceding) ? `[${raw}](task://${ref})` : raw;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findTaskReferenceMatches(
|
export function findTaskReferenceMatches(
|
||||||
|
|
@ -22,15 +62,9 @@ export function findTaskReferenceMatches(
|
||||||
): TaskReferenceMatch[] {
|
): TaskReferenceMatch[] {
|
||||||
if (!text || taskSuggestions.length === 0) return [];
|
if (!text || taskSuggestions.length === 0) return [];
|
||||||
|
|
||||||
const suggestionByRef = new Map<string, MentionSuggestion>();
|
const suggestionsByRef = buildSuggestionsByRef(taskSuggestions);
|
||||||
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 [];
|
if (suggestionsByRef.size === 0) return [];
|
||||||
|
|
||||||
const matches: TaskReferenceMatch[] = [];
|
const matches: TaskReferenceMatch[] = [];
|
||||||
for (const match of text.matchAll(TASK_REF_REGEX)) {
|
for (const match of text.matchAll(TASK_REF_REGEX)) {
|
||||||
|
|
@ -39,14 +73,10 @@ export function findTaskReferenceMatches(
|
||||||
const start = match.index ?? -1;
|
const start = match.index ?? -1;
|
||||||
if (start < 0) continue;
|
if (start < 0) continue;
|
||||||
|
|
||||||
if (start > 0) {
|
const preceding = start > 0 ? text[start - 1] : undefined;
|
||||||
const preceding = text[start - 1];
|
if (!isAllowedTaskRefBoundary(preceding)) continue;
|
||||||
if (preceding !== ' ' && preceding !== '\t' && preceding !== '\n' && preceding !== '\r') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const suggestion = suggestionByRef.get(ref.toLowerCase());
|
const suggestion = resolveTaskSuggestion(suggestionsByRef.get(ref.toLowerCase()) ?? []);
|
||||||
if (!suggestion) continue;
|
if (!suggestion) continue;
|
||||||
|
|
||||||
matches.push({
|
matches.push({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue