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:
iliya 2026-03-11 13:41:38 +02:00
parent b6ec408451
commit 057591060a
11 changed files with 198 additions and 48 deletions

View file

@ -13,7 +13,7 @@ interface MemberBadgeProps {
name: string;
color?: string;
/** Avatar + badge size variant */
size?: 'sm' | 'md';
size?: 'xs' | 'sm' | 'md';
/** Hide the avatar icon, show only the name badge */
hideAvatar?: boolean;
onClick?: (name: string) => void;
@ -37,9 +37,10 @@ export const MemberBadge = ({
}: MemberBadgeProps): React.JSX.Element => {
const colors = getTeamColorSet(color ?? '');
const { isLight } = useTheme();
const avatarSize = size === 'md' ? 32 : 24;
const avatarClass = size === 'md' ? 'size-6' : 'size-5';
const textClass = size === 'md' ? 'text-xs' : 'text-[10px]';
const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18;
const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4';
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 = {
backgroundColor: getThemedBadge(colors, isLight),
@ -58,7 +59,7 @@ export const MemberBadge = ({
const badge = (
<span
className={`rounded px-1.5 py-0.5 ${textClass} font-medium tracking-wide`}
className={`rounded ${paddingClass} ${textClass} font-medium tracking-wide`}
style={badgeStyle}
>
{name === 'team-lead' ? 'lead' : name}

View file

@ -73,27 +73,39 @@ export const TaskTooltip = ({
const globalTasks = useStore((s) => s.globalTasks);
const teamByName = useStore((s) => s.teamByName);
const tasks = useMemo(() => {
const task = useMemo(() => {
if (teamName && selectedTeamName === teamName) {
return selectedTeamData?.tasks ?? [];
return (
(selectedTeamData?.tasks ?? []).find((candidate) => taskMatchesRef(candidate, taskId)) ??
null
);
}
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 currentMatch = currentTasks.find((task) => taskMatchesRef(task, taskId));
if (currentMatch) return currentTasks;
return globalTasks;
if (currentMatch) return currentMatch;
const globalMatches = globalTasks.filter((candidate) => taskMatchesRef(candidate, taskId));
return globalMatches.length === 1 ? globalMatches[0] : null;
}, [globalTasks, selectedTeamData, selectedTeamName, teamName, taskId]);
const members = useMemo(() => {
if (teamName && selectedTeamName === teamName) {
return selectedTeamData?.members ?? [];
}
if (!teamName && task && selectedTeamName === (task as { teamName?: string }).teamName) {
return selectedTeamData?.members ?? [];
}
return [];
}, [selectedTeamData, selectedTeamName, teamName]);
const task = useMemo(() => tasks?.find((t) => taskMatchesRef(t, taskId)), [tasks, taskId]);
}, [selectedTeamData, selectedTeamName, teamName, task]);
const colorMap = useMemo(
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),

View file

@ -839,18 +839,33 @@ export const CreateTeamDialog = ({
/>
</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">
<Checkbox
id="launch-team"
className="mt-1 shrink-0"
checked={launchTeam}
onCheckedChange={(checked) => setLaunchTeam(checked === true)}
/>
<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
</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.
</p>
</div>

View file

@ -20,6 +20,7 @@ import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
@ -108,6 +109,7 @@ function getLocalTimezone(): string {
export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Element => {
const { open, onClose } = props;
const { isLight } = useTheme();
const isLaunch = props.mode === 'launch';
const isSchedule = props.mode === 'schedule';
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
*/}
{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
type="button"
className="flex w-full items-center gap-1.5 px-3 py-2 text-left"

View file

@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { ChevronRight, Settings2 } from 'lucide-react';
@ -21,6 +22,7 @@ export const OptionalSettingsSection = ({
children,
}: OptionalSettingsSectionProps): React.JSX.Element => {
const [isOpen, setIsOpen] = useState(defaultOpen);
const { isLight } = useTheme();
const visibleSummary = useMemo(
() =>
@ -31,6 +33,26 @@ export const OptionalSettingsSection = ({
[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 (
<div
className={cn(
@ -38,7 +60,7 @@ export const OptionalSettingsSection = ({
className
)}
style={{
backgroundColor: 'color-mix(in srgb, var(--color-surface-overlay) 94%, white 6%)',
backgroundColor: containerBackground,
}}
>
<button
@ -48,19 +70,29 @@ export const OptionalSettingsSection = ({
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)]">
<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" />
</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)]">
<span className="text-sm font-medium" style={{ color: headerTitleColor }}>
{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
</span>
</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 ? (
<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.join(' • ')
: 'Collapsed by default to keep the primary flow focused.'}
@ -70,9 +102,10 @@ export const OptionalSettingsSection = ({
</div>
<ChevronRight
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'
)}
style={{ color: headerIconColor }}
/>
</button>
@ -80,7 +113,7 @@ export const OptionalSettingsSection = ({
<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%)',
backgroundColor: contentBackground,
}}
>
{children}

View file

@ -8,6 +8,7 @@ import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTheme } from '@renderer/hooks/useTheme';
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { ChevronDown, ChevronRight, Info } from 'lucide-react';
@ -47,6 +48,7 @@ export const MemberDraftRow = ({
projectPath,
mentionSuggestions = [],
}: MemberDraftRowProps): React.JSX.Element => {
const { isLight } = useTheme();
const memberColorSet = getTeamColorSet(
getMemberColorByName(member.name.trim() || `member-${index}`)
);
@ -117,12 +119,19 @@ export const MemberDraftRow = ({
return (
<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={{
borderLeftWidth: '3px',
borderLeftColor: memberColorSet.border,
backgroundColor: isLight
? '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">
<Input
className="h-8 text-xs"

View file

@ -224,7 +224,7 @@ export const ChipInteractionLayer = ({
>
<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) => {
e.preventDefault();
e.stopPropagation();

View file

@ -1,7 +1,9 @@
import { useEffect, useRef } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
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 { Folder, Hash, Loader2, UsersRound } from 'lucide-react';
@ -56,6 +58,7 @@ export const MentionSuggestionList = ({
filesLoading,
}: MentionSuggestionListProps): React.JSX.Element => {
const listRef = useRef<HTMLUListElement>(null);
const { isLight } = useTheme();
useEffect(() => {
const list = listRef.current;
@ -111,6 +114,12 @@ export const MentionSuggestionList = ({
const isFileOrFolder = isFile || isFolder;
const isTeam = section === 'team';
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
if (showSections && section !== currentSection) {
@ -177,8 +186,16 @@ export const MentionSuggestionList = ({
>
<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)]">
{isTask && s.ownerName ? (
<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}
</span>
) : null}

View file

@ -868,6 +868,18 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
/>
) : 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 ? (
<div className="pointer-events-none absolute bottom-2 right-2 z-20 flex items-end justify-end">
<div className="pointer-events-auto">{cornerAction}</div>

View file

@ -71,7 +71,19 @@ export const TaskReferenceInteractionLayer = ({
width: position.width,
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) => {
if (!e.metaKey && !e.ctrlKey) return;
e.preventDefault();
e.stopPropagation();
if (teamName) {

View file

@ -4,6 +4,43 @@ import type { MentionSuggestion } from '@renderer/types/mention';
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 {
start: number;
end: number;
@ -13,7 +50,10 @@ export interface TaskReferenceMatch {
}
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(
@ -22,15 +62,9 @@ export function findTaskReferenceMatches(
): 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);
}
const suggestionsByRef = buildSuggestionsByRef(taskSuggestions);
if (suggestionByRef.size === 0) return [];
if (suggestionsByRef.size === 0) return [];
const matches: TaskReferenceMatch[] = [];
for (const match of text.matchAll(TASK_REF_REGEX)) {
@ -39,14 +73,10 @@ export function findTaskReferenceMatches(
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 preceding = start > 0 ? text[start - 1] : undefined;
if (!isAllowedTaskRefBoundary(preceding)) continue;
const suggestion = suggestionByRef.get(ref.toLowerCase());
const suggestion = resolveTaskSuggestion(suggestionsByRef.get(ref.toLowerCase()) ?? []);
if (!suggestion) continue;
matches.push({