feat: add yet-another-react-lightbox for enhanced image handling

- Integrated yet-another-react-lightbox to improve image preview functionality across various components.
- Updated ImageLightbox and AttachmentDisplay to utilize the new lightbox for displaying images.
- Enhanced TaskAttachments and TaskCommentsSection to support image lightbox previews, improving user experience when viewing attachments.
- Refactored SendMessageDialog and EditorImagePreview to ensure consistent image handling with the new lightbox implementation.
This commit is contained in:
iliya 2026-03-05 16:45:23 +02:00
parent f1d08e6d33
commit 8ada8dfcf5
19 changed files with 531 additions and 346 deletions

View file

@ -134,6 +134,7 @@
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5", "unified": "^11.0.5",
"yet-another-react-lightbox": "^3.29.1",
"zustand": "^4.5.0" "zustand": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -230,6 +230,9 @@ importers:
unified: unified:
specifier: ^11.0.5 specifier: ^11.0.5
version: 11.0.5 version: 11.0.5
yet-another-react-lightbox:
specifier: ^3.29.1
version: 3.29.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
zustand: zustand:
specifier: ^4.5.0 specifier: ^4.5.0
version: 4.5.7(@types/react@18.3.27)(react@18.3.1) version: 4.5.7(@types/react@18.3.27)(react@18.3.1)
@ -6164,6 +6167,20 @@ packages:
yauzl@2.10.0: yauzl@2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
yet-another-react-lightbox@3.29.1:
resolution: {integrity: sha512-0cpa+wlleiy2cWNjS9qrcY0+SgZQH/4PDx2uupLMI9Ofip1f7pCgZ95PlVp/EsFsO4ufwOTea51bkLhcEXJJSg==}
engines: {node: '>=14'}
peerDependencies:
'@types/react': ^16 || ^17 || ^18 || ^19
'@types/react-dom': ^16 || ^17 || ^18 || ^19
react: ^16.8.0 || ^17 || ^18 || ^19
react-dom: ^16.8.0 || ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
yocto-queue@0.1.0: yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -13183,6 +13200,14 @@ snapshots:
buffer-crc32: 0.2.13 buffer-crc32: 0.2.13
fd-slicer: 1.1.0 fd-slicer: 1.1.0
yet-another-react-lightbox@3.29.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
zip-stream@4.1.1: zip-stream@4.1.1:

View file

@ -1516,7 +1516,11 @@ export class TeamAgentToolsInstaller {
} }
if (current?.includes(`TOOL_VERSION = '${APP_VERSION}'`)) { if (current?.includes(`TOOL_VERSION = '${APP_VERSION}'`)) {
return toolPath; // Even when app version is unchanged, the generated script can evolve.
// Keep the installed tool idempotent by content, not only by version.
if (current === desired) {
return toolPath;
}
} }
await atomicWriteAsync(toolPath, desired); await atomicWriteAsync(toolPath, desired);

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ChevronDown, Columns3, History, MessageSquare, Users } from 'lucide-react'; import { ChevronDown, Columns3, History, MessageSquare, Terminal, Users } from 'lucide-react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
@ -14,6 +14,7 @@ const SECTIONS: readonly { id: string; label: string; icon: LucideIcon }[] = [
{ id: 'team', label: 'Team', icon: Users }, { id: 'team', label: 'Team', icon: Users },
{ id: 'sessions', label: 'Sessions', icon: History }, { id: 'sessions', label: 'Sessions', icon: History },
{ id: 'kanban', label: 'Kanban', icon: Columns3 }, { id: 'kanban', label: 'Kanban', icon: Columns3 },
{ id: 'claude-logs', label: 'Claude Logs', icon: Terminal },
{ id: 'messages', label: 'Messages', icon: MessageSquare }, { id: 'messages', label: 'Messages', icon: MessageSquare },
]; ];

View file

@ -157,8 +157,17 @@ export const GlobalTaskList = ({
variant: 'danger', variant: 'danger',
}); });
if (confirmed) { if (confirmed) {
await softDeleteTask(teamName, taskId); try {
await fetchAllTasks(); await softDeleteTask(teamName, taskId);
await fetchAllTasks();
} catch (err) {
void confirm({
title: 'Failed to delete task',
message: err instanceof Error ? err.message : 'An unexpected error occurred',
confirmLabel: 'OK',
variant: 'danger',
});
}
} }
}; };

View file

@ -191,7 +191,7 @@ export const ProvisioningProgressBlock = ({
{message} {message}
</p> </p>
) : null} ) : null}
<div className="mt-2 flex items-center gap-1 overflow-x-auto pb-0.5"> <div className="mt-2 flex items-center justify-center gap-1 overflow-x-auto pb-0.5">
{STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => { {STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => {
const isDone = currentStepIndex >= 0 && index < currentStepIndex; const isDone = currentStepIndex >= 0 && index < currentStepIndex;
const isCurrent = currentStepIndex >= 0 && index === currentStepIndex; const isCurrent = currentStepIndex >= 0 && index === currentStepIndex;

View file

@ -220,6 +220,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
clearProvisioningError, clearProvisioningError,
isTeamProvisioning, isTeamProvisioning,
leadActivityByTeam, leadActivityByTeam,
leadContextByTeam,
refreshTeamData, refreshTeamData,
kanbanFilterQuery, kanbanFilterQuery,
clearKanbanFilter, clearKanbanFilter,
@ -262,6 +263,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state) (run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
), ),
leadActivityByTeam: s.leadActivityByTeam, leadActivityByTeam: s.leadActivityByTeam,
leadContextByTeam: s.leadContextByTeam,
refreshTeamData: s.refreshTeamData, refreshTeamData: s.refreshTeamData,
kanbanFilterQuery: s.kanbanFilterQuery, kanbanFilterQuery: s.kanbanFilterQuery,
clearKanbanFilter: s.clearKanbanFilter, clearKanbanFilter: s.clearKanbanFilter,
@ -377,6 +379,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0); const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0);
return total > 0 ? total : undefined; return total > 0 ? total : undefined;
}, [leadSessionDetail?.processes]); }, [leadSessionDetail?.processes]);
const leadContextPercent = leadContextByTeam[teamName]?.percent;
const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => { const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => {
if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) { if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) {
@ -431,6 +434,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens }; return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
}, [leadSessionLoaded, leadSessionContextStats, leadConversation, selectedContextPhase, leadSessionPhaseInfo]); }, [leadSessionLoaded, leadSessionContextStats, leadConversation, selectedContextPhase, leadSessionPhaseInfo]);
const visibleContextTokens = useMemo(() => {
return allContextInjections.reduce((sum, inj) => sum + (inj.estimatedTokens ?? 0), 0);
}, [allContextInjections]);
const visibleContextPercentOfTotal = useMemo(() => {
if (lastAiGroupTotalTokens === undefined || lastAiGroupTotalTokens <= 0) return null;
return Math.min((visibleContextTokens / lastAiGroupTotalTokens) * 100, 100);
}, [visibleContextTokens, lastAiGroupTotalTokens]);
useEffect(() => { useEffect(() => {
if (!projectId) return; if (!projectId) return;
@ -715,7 +727,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
variant: 'danger', variant: 'danger',
}); });
if (confirmed) { if (confirmed) {
void softDeleteTask(teamName, taskId); try {
await softDeleteTask(teamName, taskId);
} catch {
// error via store
}
} }
})(); })();
}, },
@ -844,12 +860,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
<div className="flex size-full overflow-hidden"> <div className="flex size-full overflow-hidden">
<div <div
ref={contentRef} ref={contentRef}
className="size-full flex-1 overflow-auto p-4" className="relative size-full flex-1 overflow-auto p-4"
data-team-name={teamName} data-team-name={teamName}
> >
{/* Sticky Context button (same interaction as Session view) */} {/* Sticky context button in top-right while scrolling */}
{leadSessionId && ( {leadSessionId && (
<div className="pointer-events-none sticky top-0 z-10 flex justify-end pb-0 pt-3"> <div className="pointer-events-none sticky top-0 z-20 ml-auto w-fit pb-2">
<button <button
onClick={() => { onClick={() => {
const next = !isContextPanelVisible; const next = !isContextPanelVisible;
@ -879,7 +895,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
: leadSessionId : leadSessionId
} }
> >
{leadSessionLoaded ? `Context (${allContextInjections.length})` : 'Context'} {visibleContextPercentOfTotal !== null
? `${visibleContextPercentOfTotal.toFixed(1)}% of total`
: typeof leadContextPercent === 'number'
? `${Math.round(leadContextPercent)}%`
: 'Context'}
</button> </button>
</div> </div>
)} )}
@ -1192,18 +1212,34 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</div> </div>
} }
onRequestReview={(taskId) => { onRequestReview={(taskId) => {
void requestReview(teamName, taskId); void (async () => {
try {
await requestReview(teamName, taskId);
} catch {
// error via store
}
})();
}} }}
onApprove={(taskId) => { onApprove={(taskId) => {
void updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); void (async () => {
try {
await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' });
} catch {
// error via store
}
})();
}} }}
onRequestChanges={(taskId) => { onRequestChanges={(taskId) => {
setRequestChangesTaskId(taskId); setRequestChangesTaskId(taskId);
}} }}
onMoveBackToDone={(taskId) => { onMoveBackToDone={(taskId) => {
void (async () => { void (async () => {
await updateKanban(teamName, taskId, { op: 'remove' }); try {
await updateTaskStatus(teamName, taskId, 'completed'); await updateKanban(teamName, taskId, { op: 'remove' });
await updateTaskStatus(teamName, taskId, 'completed');
} catch {
// error via store
}
})(); })();
}} }}
onStartTask={(taskId) => { onStartTask={(taskId) => {
@ -1237,7 +1273,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
})(); })();
}} }}
onCompleteTask={(taskId) => { onCompleteTask={(taskId) => {
void updateTaskStatus(teamName, taskId, 'completed'); void (async () => {
try {
await updateTaskStatus(teamName, taskId, 'completed');
} catch {
// error via store
}
})();
}} }}
onCancelTask={(taskId) => { onCancelTask={(taskId) => {
void (async () => { void (async () => {

View file

@ -382,7 +382,11 @@ export const TeamListView = (): React.JSX.Element => {
variant: 'danger', variant: 'danger',
}); });
if (confirmed) { if (confirmed) {
void deleteTeam(teamName); try {
await deleteTeam(teamName);
} catch {
// error via store
}
} }
})(); })();
}, },
@ -392,7 +396,13 @@ export const TeamListView = (): React.JSX.Element => {
const handleRestoreTeam = useCallback( const handleRestoreTeam = useCallback(
(teamName: string, e: React.MouseEvent) => { (teamName: string, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
void restoreTeam(teamName); void (async () => {
try {
await restoreTeam(teamName);
} catch {
// error via store
}
})();
}, },
[restoreTeam] [restoreTeam]
); );
@ -409,7 +419,11 @@ export const TeamListView = (): React.JSX.Element => {
variant: 'danger', variant: 'danger',
}); });
if (confirmed) { if (confirmed) {
void permanentlyDeleteTeam(teamName); try {
await permanentlyDeleteTeam(teamName);
} catch {
// error via store
}
} }
})(); })();
}, },

View file

@ -87,10 +87,10 @@ export const AttachmentDisplay = ({
</div> </div>
{lightboxIndex !== null && items[lightboxIndex] ? ( {lightboxIndex !== null && items[lightboxIndex] ? (
<ImageLightbox <ImageLightbox
src={items[lightboxIndex].dataUrl}
alt={items[lightboxIndex].meta.filename}
open open
onClose={() => setLightboxIndex(null)} onClose={() => setLightboxIndex(null)}
slides={items.map((item) => ({ src: item.dataUrl, alt: item.meta.filename }))}
index={lightboxIndex}
/> />
) : null} ) : null}
</> </>

View file

@ -1,58 +1,85 @@
import { useCallback, useEffect } from 'react'; import { useMemo } from 'react';
interface ImageLightboxProps { import Lightbox from 'yet-another-react-lightbox';
import Counter from 'yet-another-react-lightbox/plugins/counter';
import Fullscreen from 'yet-another-react-lightbox/plugins/fullscreen';
import Zoom from 'yet-another-react-lightbox/plugins/zoom';
import 'yet-another-react-lightbox/styles.css';
import 'yet-another-react-lightbox/plugins/counter.css';
import type { Plugin, Slide } from 'yet-another-react-lightbox';
export interface ImageLightboxSlide {
src: string; src: string;
alt?: string; alt?: string;
title?: string;
}
interface ImageLightboxProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
/** Array of slides for gallery mode. */
slides?: ImageLightboxSlide[];
/** Starting slide index (default: 0). */
index?: number;
/** Single image src — convenience shorthand for `slides={[{ src }]}`. */
src?: string;
/** Alt text for single-image mode. */
alt?: string;
enableZoom?: boolean;
enableFullscreen?: boolean;
showCounter?: boolean;
} }
export const ImageLightbox = ({ export const ImageLightbox = ({
src,
alt = 'Image',
open, open,
onClose, onClose,
slides: slidesProp,
index = 0,
src,
alt,
enableZoom = true,
enableFullscreen = true,
showCounter,
}: ImageLightboxProps): React.JSX.Element | null => { }: ImageLightboxProps): React.JSX.Element | null => {
const handleKeyDown = useCallback( const slides = useMemo<Slide[]>(() => {
(e: KeyboardEvent) => { if (slidesProp && slidesProp.length > 0) {
if (e.key === 'Escape') onClose(); return slidesProp.map((s) => ({ src: s.src, alt: s.alt, title: s.title }));
}, }
[onClose] if (src) {
); return [{ src, alt }];
}
return [];
}, [slidesProp, src, alt]);
useEffect(() => { const plugins = useMemo<Plugin[]>(() => {
if (!open) return; const list: Plugin[] = [];
document.addEventListener('keydown', handleKeyDown); if (enableZoom) list.push(Zoom);
return () => document.removeEventListener('keydown', handleKeyDown); if (enableFullscreen) list.push(Fullscreen);
}, [open, handleKeyDown]); // Show counter only when multiple slides (unless explicitly set)
const shouldShowCounter = showCounter ?? slides.length > 1;
if (shouldShowCounter) list.push(Counter);
return list;
}, [enableZoom, enableFullscreen, showCounter, slides.length]);
if (!open) return null; if (!open || slides.length === 0) return null;
return ( return (
<div <Lightbox
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm duration-150 animate-in fade-in" open={open}
role="dialog" close={onClose}
aria-modal="true" slides={slides}
aria-label={alt} index={index}
> plugins={plugins}
<button carousel={{ finite: slides.length <= 1 }}
type="button" animation={{ fade: 200 }}
className="absolute inset-0 border-0 bg-transparent p-0" zoom={{
onClick={onClose} maxZoomPixelRatio: 5,
aria-label="Close" scrollToZoom: true,
/> }}
<button styles={{
type="button" container: { backgroundColor: 'rgba(0, 0, 0, 0.85)', backdropFilter: 'blur(8px)' },
className="relative z-10 max-h-[85vh] max-w-[90vw] border-0 bg-transparent p-0" }}
onClick={(e) => e.stopPropagation()} />
>
<img
src={src}
alt={alt}
className="rounded-lg object-contain shadow-2xl"
draggable={false}
/>
</button>
</div>
); );
}; };

View file

@ -14,10 +14,9 @@ import {
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input'; import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label'; import { Label } from '@renderer/components/ui/label';
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Combobox, type ComboboxOption } from '@renderer/components/ui/combobox';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useAttachments } from '@renderer/hooks/useAttachments'; import { useAttachments } from '@renderer/hooks/useAttachments';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
@ -28,7 +27,7 @@ import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { AlertCircle, Check, ImagePlus, Send, X } from 'lucide-react'; import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
import { MemberBadge } from '../MemberBadge'; import { MemberBadge } from '../MemberBadge';
@ -82,15 +81,6 @@ export const SendMessageDialog = ({
onClose, onClose,
}: SendMessageDialogProps): React.JSX.Element => { }: SendMessageDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const recipientOptions = useMemo<ComboboxOption[]>(
() =>
members.map((m) => ({
value: m.name,
label: m.name,
description: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
})),
[members]
);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined); const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
const [quoteExpanded, setQuoteExpanded] = useState(false); const [quoteExpanded, setQuoteExpanded] = useState(false);
@ -286,43 +276,12 @@ export const SendMessageDialog = ({
<div className="grid gap-4 py-2"> <div className="grid gap-4 py-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="smd-recipient">Recipient</Label> <Label htmlFor="smd-recipient">Recipient</Label>
<Combobox <MemberSelect
value={member} members={members}
onValueChange={setMember} value={member || null}
onChange={(v) => setMember(v ?? '')}
placeholder="Select member..." placeholder="Select member..."
searchPlaceholder="Search members..." size="sm"
emptyMessage="No members found."
options={recipientOptions}
renderOption={(option, isSelected) => {
const resolvedColor = colorMap.get(option.value);
const optionColorSet = resolvedColor ? getTeamColorSet(resolvedColor) : null;
return (
<>
{optionColorSet ? (
<span
className="mr-2 inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: optionColorSet.border }}
/>
) : (
<span className="mr-2 inline-block size-2 shrink-0 rounded-full bg-[var(--color-text-muted)]" />
)}
<span
className="min-w-0 truncate font-medium"
style={optionColorSet ? { color: optionColorSet.text } : undefined}
>
{option.label}
</span>
{option.description ? (
<span className="ml-1 shrink-0 text-[10px] text-[var(--color-text-muted)]">
{option.description}
</span>
) : null}
{isSelected ? (
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
) : null}
</>
);
}}
/> />
</div> </div>

View file

@ -2,8 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { File, ImagePlus, Loader2, Trash2, X } from 'lucide-react'; import { File, ImagePlus, Loader2, Trash2 } from 'lucide-react';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import { isImageMimeType } from '@renderer/utils/attachmentUtils';
import type { TaskAttachmentMeta } from '@shared/types'; import type { TaskAttachmentMeta } from '@shared/types';
@ -30,14 +31,21 @@ export const TaskAttachments = ({
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [previewAttachment, setPreviewAttachment] = useState<{ const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
id: string; const [thumbCache, setThumbCache] = useState<Map<string, string>>(new Map());
mimeType: string;
dataUrl: string | null;
loading: boolean;
} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const imageAttachments = attachments.filter((a) => isImageMimeType(a.mimeType));
const handleThumbLoaded = useCallback((attachmentId: string, dataUrl: string) => {
setThumbCache((prev) => {
if (prev.get(attachmentId) === dataUrl) return prev;
const next = new Map(prev);
next.set(attachmentId, dataUrl);
return next;
});
}, []);
const handleFileSelect = useCallback( const handleFileSelect = useCallback(
async (files: FileList | null) => { async (files: FileList | null) => {
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
@ -79,16 +87,13 @@ export const TaskAttachments = ({
setDeletingId(attachmentId); setDeletingId(attachmentId);
try { try {
await deleteTaskAttachment(teamName, taskId, attachmentId, mimeType); await deleteTaskAttachment(teamName, taskId, attachmentId, mimeType);
if (previewAttachment?.id === attachmentId) {
setPreviewAttachment(null);
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete'); setError(err instanceof Error ? err.message : 'Failed to delete');
} finally { } finally {
setDeletingId(null); setDeletingId(null);
} }
}, },
[teamName, taskId, deleteTaskAttachment, previewAttachment] [teamName, taskId, deleteTaskAttachment]
); );
const handleDownload = useCallback( const handleDownload = useCallback(
@ -119,35 +124,15 @@ export const TaskAttachments = ({
); );
const handlePreview = useCallback( const handlePreview = useCallback(
async (att: TaskAttachmentMeta) => { (att: TaskAttachmentMeta) => {
if (!isImageMimeType(att.mimeType)) { if (!isImageMimeType(att.mimeType)) {
void handleDownload(att); void handleDownload(att);
return; return;
} }
if (previewAttachment?.id === att.id && previewAttachment.dataUrl) { const idx = imageAttachments.findIndex((a) => a.id === att.id);
setPreviewAttachment(null); if (idx >= 0) setLightboxIndex(idx);
return;
}
setPreviewAttachment({ id: att.id, mimeType: att.mimeType, dataUrl: null, loading: true });
try {
const base64 = await getTaskAttachmentData(teamName, taskId, att.id, att.mimeType);
if (base64) {
setPreviewAttachment({
id: att.id,
mimeType: att.mimeType,
dataUrl: `data:${att.mimeType};base64,${base64}`,
loading: false,
});
} else {
setPreviewAttachment(null);
setError('Attachment file not found');
}
} catch {
setPreviewAttachment(null);
setError('Failed to load attachment');
}
}, },
[teamName, taskId, getTaskAttachmentData, previewAttachment, handleDownload] [imageAttachments, handleDownload]
); );
// Handle paste events for quick image attachment // Handle paste events for quick image attachment
@ -212,38 +197,28 @@ export const TaskAttachments = ({
teamName={teamName} teamName={teamName}
taskId={taskId} taskId={taskId}
isDeleting={deletingId === att.id} isDeleting={deletingId === att.id}
isPreviewActive={previewAttachment?.id === att.id}
onPreview={() => void handlePreview(att)} onPreview={() => void handlePreview(att)}
onDelete={() => void handleDelete(att.id, att.mimeType)} onDelete={() => void handleDelete(att.id, att.mimeType)}
onDataLoaded={handleThumbLoaded}
/> />
))} ))}
</div> </div>
) : null} ) : null}
{/* Preview panel */} {/* Image lightbox */}
{previewAttachment ? ( {lightboxIndex !== null && (
<div className="relative rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2"> <ImageLightbox
<button open
type="button" onClose={() => setLightboxIndex(null)}
className="absolute right-2 top-2 rounded p-0.5 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]" slides={imageAttachments
onClick={() => setPreviewAttachment(null)} .map((att) => {
> const dataUrl = thumbCache.get(att.id);
<X size={14} /> return dataUrl ? { src: dataUrl, alt: att.filename } : null;
</button> })
{previewAttachment.loading ? ( .filter(Boolean) as { src: string; alt: string }[]}
<div className="flex items-center gap-2 py-4 text-xs text-[var(--color-text-muted)]"> index={lightboxIndex}
<Loader2 size={14} className="animate-spin" /> />
Loading image... )}
</div>
) : previewAttachment.dataUrl ? (
<img
src={previewAttachment.dataUrl}
alt="Attachment preview"
className="max-h-[400px] max-w-full rounded object-contain"
/>
) : null}
</div>
) : null}
{/* Drop zone indicator */} {/* Drop zone indicator */}
{dragOver ? ( {dragOver ? (
@ -289,9 +264,9 @@ interface AttachmentThumbnailProps {
teamName: string; teamName: string;
taskId: string; taskId: string;
isDeleting: boolean; isDeleting: boolean;
isPreviewActive: boolean;
onPreview: () => void; onPreview: () => void;
onDelete: () => void; onDelete: () => void;
onDataLoaded?: (attachmentId: string, dataUrl: string) => void;
} }
const AttachmentThumbnail = ({ const AttachmentThumbnail = ({
@ -299,9 +274,9 @@ const AttachmentThumbnail = ({
teamName, teamName,
taskId, taskId,
isDeleting, isDeleting,
isPreviewActive,
onPreview, onPreview,
onDelete, onDelete,
onDataLoaded,
}: AttachmentThumbnailProps): React.JSX.Element => { }: AttachmentThumbnailProps): React.JSX.Element => {
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData); const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
const [thumbUrl, setThumbUrl] = useState<string | null>(null); const [thumbUrl, setThumbUrl] = useState<string | null>(null);
@ -318,7 +293,9 @@ const AttachmentThumbnail = ({
attachment.mimeType attachment.mimeType
); );
if (!cancelled && base64) { if (!cancelled && base64) {
setThumbUrl(`data:${attachment.mimeType};base64,${base64}`); const dataUrl = `data:${attachment.mimeType};base64,${base64}`;
setThumbUrl(dataUrl);
onDataLoaded?.(attachment.id, dataUrl);
} }
} catch { } catch {
// ignore // ignore
@ -327,7 +304,7 @@ const AttachmentThumbnail = ({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]); }, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData, onDataLoaded]);
const sizeLabel = const sizeLabel =
attachment.size < 1024 attachment.size < 1024
@ -338,11 +315,7 @@ const AttachmentThumbnail = ({
return ( return (
<div <div
className={`group relative flex size-20 cursor-pointer items-center justify-center overflow-hidden rounded border transition-colors ${ className={`group relative flex size-20 cursor-pointer items-center justify-center overflow-hidden rounded border transition-colors border-[var(--color-border)] hover:border-[var(--color-border-emphasis)] bg-[var(--color-surface)]`}
isPreviewActive
? 'border-blue-500/60 ring-1 ring-blue-500/30'
: 'border-[var(--color-border)] hover:border-[var(--color-border-emphasis)]'
} bg-[var(--color-surface)]`}
onClick={onPreview} onClick={onPreview}
> >
{isImageMimeType(attachment.mimeType) ? ( {isImageMimeType(attachment.mimeType) ? (

View file

@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock'; import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@ -305,22 +306,14 @@ export const TaskCommentsSection = ({
</div> </div>
) : null} ) : null}
{/* Full-size image preview overlay */} {/* Image lightbox */}
{previewImageUrl ? ( {previewImageUrl ? (
<div className="relative mb-3 rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2"> <ImageLightbox
<button open
type="button" onClose={() => setPreviewImageUrl(null)}
className="absolute right-2 top-2 rounded p-0.5 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]" src={previewImageUrl}
onClick={() => setPreviewImageUrl(null)} alt="Attachment preview"
> />
<X size={14} />
</button>
<img
src={previewImageUrl}
alt="Attachment preview"
className="max-h-[400px] max-w-full rounded object-contain"
/>
</div>
) : null} ) : null}
{!hideInput && ( {!hideInput && (
@ -416,6 +409,7 @@ const CommentAttachmentThumbnail = ({
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData); const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
const [thumbUrl, setThumbUrl] = useState<string | null>(null); const [thumbUrl, setThumbUrl] = useState<string | null>(null);
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -441,58 +435,76 @@ const CommentAttachmentThumbnail = ({
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]); }, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]);
return ( return (
<div <Tooltip>
className="group relative flex size-14 cursor-pointer items-center justify-center overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] transition-colors hover:border-[var(--color-border-emphasis)]" <TooltipTrigger asChild>
onClick={() => { <div
if (isImageMimeType(attachment.mimeType)) { className={`group relative flex size-14 cursor-pointer items-center justify-center overflow-hidden rounded border bg-[var(--color-surface)] transition-colors ${
if (thumbUrl) onPreview(thumbUrl); downloadError
return; ? 'border-red-500/60'
} : 'border-[var(--color-border)] hover:border-[var(--color-border-emphasis)]'
void (async () => { }`}
setDownloading(true); onClick={() => {
try { if (isImageMimeType(attachment.mimeType)) {
const base64 = await getTaskAttachmentData( if (thumbUrl) onPreview(thumbUrl);
teamName, return;
taskId, }
attachment.id, void (async () => {
attachment.mimeType setDownloading(true);
); setDownloadError(null);
if (!base64) return; try {
const mime = const base64 = await getTaskAttachmentData(
attachment.mimeType && typeof attachment.mimeType === 'string' teamName,
? attachment.mimeType taskId,
: 'application/octet-stream'; attachment.id,
const dataUrl = `data:${mime};base64,${base64}`; attachment.mimeType
const blob = await fetch(dataUrl).then((r) => r.blob()); );
const url = URL.createObjectURL(blob); if (!base64) return;
const a = document.createElement('a'); const mime =
a.href = url; attachment.mimeType && typeof attachment.mimeType === 'string'
a.download = attachment.filename || 'attachment'; ? attachment.mimeType
document.body.appendChild(a); : 'application/octet-stream';
a.click(); const dataUrl = `data:${mime};base64,${base64}`;
a.remove(); const blob = await fetch(dataUrl).then((r) => r.blob());
URL.revokeObjectURL(url); const url = URL.createObjectURL(blob);
} finally { const a = document.createElement('a');
setDownloading(false); a.href = url;
} a.download = attachment.filename || 'attachment';
})(); document.body.appendChild(a);
}} a.click();
> a.remove();
{isImageMimeType(attachment.mimeType) ? ( URL.revokeObjectURL(url);
thumbUrl ? ( } catch (err) {
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" /> setDownloadError(err instanceof Error ? err.message : 'Download failed');
) : ( } finally {
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" /> setDownloading(false);
) }
) : downloading ? ( })();
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" /> }}
>
{isImageMimeType(attachment.mimeType) ? (
thumbUrl ? (
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
) : (
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" />
)
) : downloading ? (
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" />
) : (
<File size={14} className="text-[var(--color-text-muted)]" />
)}
<div className="absolute inset-x-0 bottom-0 truncate bg-black/60 px-0.5 py-px text-center text-[7px] text-white opacity-0 transition-opacity group-hover:opacity-100">
{attachment.filename}
</div>
</div>
</TooltipTrigger>
{downloadError ? (
<TooltipContent side="top" className="text-red-400">
{downloadError}
</TooltipContent>
) : ( ) : (
<File size={14} className="text-[var(--color-text-muted)]" /> <TooltipContent side="top">{attachment.filename}</TooltipContent>
)} )}
<div className="absolute inset-x-0 bottom-0 truncate bg-black/60 px-0.5 py-px text-center text-[7px] text-white opacity-0 transition-opacity group-hover:opacity-100"> </Tooltip>
{attachment.filename}
</div>
</div>
); );
}; };

View file

@ -7,6 +7,7 @@ import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -16,19 +17,10 @@ import {
DialogTitle, DialogTitle,
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input'; import { Input } from '@renderer/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { Textarea } from '@renderer/components/ui/textarea'; import { Textarea } from '@renderer/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { markAsRead } from '@renderer/services/commentReadStorage'; import { markAsRead } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { import {
buildMemberColorMap, buildMemberColorMap,
KANBAN_COLUMN_DISPLAY, KANBAN_COLUMN_DISPLAY,
@ -350,42 +342,14 @@ export const TaskDetailDialog = ({
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs"> <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
{canReassign ? ( {canReassign ? (
<Select <MemberSelect
value={currentTask.owner ?? '__unassigned__'} members={members}
onValueChange={(v) => { value={currentTask.owner ?? null}
onOwnerChange(currentTask.id, v === '__unassigned__' ? null : v); onChange={(v) => onOwnerChange(currentTask.id, v)}
}} allowUnassigned
> size="sm"
<SelectTrigger className="h-8 w-auto min-w-[140px] text-xs"> className="min-w-[160px]"
<SelectValue placeholder="Unassigned" /> />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unassigned__">Unassigned</SelectItem>
{members.map((m) => {
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const resolvedColor = colorMap.get(m.name);
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
return (
<SelectItem key={m.name} value={m.name}>
<span className="inline-flex items-center gap-1.5">
{memberColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: memberColor.border }}
/>
) : null}
<span style={memberColor ? { color: memberColor.text } : undefined}>
{m.name}
</span>
{role ? (
<span className="text-[var(--color-text-muted)]">({role})</span>
) : null}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
) : currentTask.owner ? ( ) : currentTask.owner ? (
<MemberBadge <MemberBadge
name={currentTask.owner} name={currentTask.owner}

View file

@ -126,10 +126,10 @@ export const EditorImagePreview = ({
</div> </div>
<ImageLightbox <ImageLightbox
src={dataUrl}
alt={fileName}
open={lightboxOpen} open={lightboxOpen}
onClose={() => setLightboxOpen(false)} onClose={() => setLightboxOpen(false)}
src={dataUrl}
alt={fileName}
/> />
</div> </div>
); );

View file

@ -270,9 +270,7 @@ export const KanbanTaskCard = ({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{task.owner ? ( {task.owner ? (
<MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> <MemberBadge name={task.owner} color={colorMap.get(task.owner)} />
) : ( ) : null}
<span className="text-xs text-[var(--color-text-muted)]">Unassigned</span>
)}
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />} {!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
</div> </div>
{task.needsClarification ? ( {task.needsClarification ? (

View file

@ -14,7 +14,7 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
<tr className="border-t border-[var(--color-border)]"> <tr className="border-t border-[var(--color-border)]">
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.id}</td> <td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.id}</td>
<td className="px-3 py-2 text-sm text-[var(--color-text)]">{task.subject}</td> <td className="px-3 py-2 text-sm text-[var(--color-text)]">{task.subject}</td>
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.owner ?? 'Unassigned'}</td> <td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.owner ?? '\u2014'}</td>
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]"> <td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
{task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY {task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY
? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label ? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label

View file

@ -0,0 +1,203 @@
import * as React from 'react';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { cn } from '@renderer/lib/utils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { Command as CommandPrimitive } from 'cmdk';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
import type { ResolvedTeamMember } from '@shared/types';
interface MemberSelectProps {
members: ResolvedTeamMember[];
value: string | null;
onChange: (value: string | null) => void;
placeholder?: string;
/** Show "Unassigned" option at the top of the list */
allowUnassigned?: boolean;
/** Size variant */
size?: 'sm' | 'md';
disabled?: boolean;
className?: string;
}
const UNASSIGNED_VALUE = '__unassigned__';
export const MemberSelect = ({
members,
value,
onChange,
placeholder = 'Select member...',
allowUnassigned = false,
size = 'sm',
disabled = false,
className,
}: MemberSelectProps): React.JSX.Element => {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const listboxId = React.useId();
const colorMap = React.useMemo(() => buildMemberColorMap(members), [members]);
const selectedMember = React.useMemo(
() => (value ? members.find((m) => m.name === value) : null),
[members, value],
);
const avatarSize = size === 'md' ? 32 : 24;
const avatarClass = size === 'md' ? 'size-6' : 'size-5';
const textSize = size === 'md' ? 'text-xs' : 'text-[10px]';
const triggerHeight = size === 'md' ? 'h-9' : 'h-8';
const renderMemberInline = (member: ResolvedTeamMember): React.ReactNode => {
const resolvedColor = colorMap.get(member.name);
const colors = getTeamColorSet(resolvedColor ?? '');
return (
<span className="inline-flex items-center gap-1.5">
<img
src={agentAvatarUrl(member.name, avatarSize)}
alt=""
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
loading="lazy"
/>
<span
className={`rounded px-1.5 py-0.5 ${textSize} font-medium tracking-wide`}
style={{
backgroundColor: colors.badge,
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
>
{member.name === 'team-lead' ? 'lead' : member.name}
</span>
</span>
);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
disabled={disabled}
className={cn(
`flex ${triggerHeight} w-full items-center justify-between rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1 text-xs shadow-sm transition-colors placeholder:text-[var(--color-text-muted)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] disabled:cursor-not-allowed disabled:opacity-50`,
className,
)}
>
<span className="min-w-0 truncate text-left">
{selectedMember ? (
renderMemberInline(selectedMember)
) : value === null && allowUnassigned ? (
<span className="text-xs text-[var(--color-text-muted)]">Unassigned</span>
) : (
<span className="text-[var(--color-text-muted)]">{placeholder}</span>
)}
</span>
<ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] min-w-[200px] p-0"
align="start"
sideOffset={4}
collisionPadding={8}
avoidCollisions
>
<CommandPrimitive
className="flex size-full flex-col overflow-hidden rounded-md bg-[var(--color-surface)]"
shouldFilter={false}
>
<div className="flex items-center border-b border-[var(--color-border)]">
<CommandPrimitive.Input
value={search}
onValueChange={setSearch}
placeholder="Search members..."
className="flex h-8 w-full border-0 bg-transparent px-2 py-1 text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]"
/>
</div>
<CommandPrimitive.List
id={listboxId}
className="max-h-72 overflow-y-auto overscroll-contain px-2 py-1"
onWheel={(e) => e.stopPropagation()}
>
<CommandPrimitive.Empty className="py-4 pr-2 text-center text-xs text-[var(--color-text-muted)]">
No members found.
</CommandPrimitive.Empty>
{allowUnassigned && !search.trim() ? (
<CommandPrimitive.Item
value={UNASSIGNED_VALUE}
onSelect={() => {
onChange(null);
setOpen(false);
setSearch('');
}}
className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
>
<span className="text-[var(--color-text-muted)]">Unassigned</span>
{value === null ? (
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
) : null}
</CommandPrimitive.Item>
) : null}
{members
.filter((m) => {
if (!search.trim()) return true;
const q = search.toLowerCase();
return (
m.name.toLowerCase().includes(q) ||
(m.role?.toLowerCase().includes(q) ?? false) ||
(m.agentType?.toLowerCase().includes(q) ?? false)
);
})
.map((m) => {
const isSelected = m.name === value;
const resolvedColor = colorMap.get(m.name);
const colors = getTeamColorSet(resolvedColor ?? '');
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
return (
<CommandPrimitive.Item
key={m.name}
value={m.name}
onSelect={() => {
onChange(m.name);
setOpen(false);
setSearch('');
}}
className="relative flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
>
<img
src={agentAvatarUrl(m.name, avatarSize)}
alt=""
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
loading="lazy"
/>
<span
className="min-w-0 truncate font-medium"
style={{ color: colors.text }}
>
{m.name === 'team-lead' ? 'lead' : m.name}
</span>
{role ? (
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{role}
</span>
) : null}
{isSelected ? (
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
) : null}
</CommandPrimitive.Item>
);
})}
</CommandPrimitive.List>
</CommandPrimitive>
</PopoverContent>
</Popover>
);
};

View file

@ -8,58 +8,11 @@ vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: vi.fn(() => []) }, BrowserWindow: { getAllWindows: vi.fn(() => []) },
})); }));
vi.mock('@preload/constants/ipcChannels', () => ({ // Keep this mock resilient to new exports (avoid drift).
TEAM_LIST: 'team:list', vi.mock('@preload/constants/ipcChannels', async (importOriginal) => {
TEAM_GET_DATA: 'team:getData', const actual = await importOriginal<typeof import('@preload/constants/ipcChannels')>();
TEAM_DELETE_TEAM: 'team:deleteTeam', return { ...actual };
TEAM_PREPARE_PROVISIONING: 'team:prepareProvisioning', });
TEAM_CREATE: 'team:create',
TEAM_LAUNCH: 'team:launch',
TEAM_CREATE_CONFIG: 'team:createConfig',
TEAM_CREATE_TASK: 'team:createTask',
TEAM_PROVISIONING_STATUS: 'team:provisioningStatus',
TEAM_CANCEL_PROVISIONING: 'team:cancelProvisioning',
TEAM_PROVISIONING_PROGRESS: 'team:provisioningProgress',
TEAM_SEND_MESSAGE: 'team:sendMessage',
TEAM_REQUEST_REVIEW: 'team:requestReview',
TEAM_UPDATE_KANBAN: 'team:updateKanban',
TEAM_UPDATE_KANBAN_COLUMN_ORDER: 'team:updateKanbanColumnOrder',
TEAM_UPDATE_TASK_STATUS: 'team:updateTaskStatus',
TEAM_UPDATE_TASK_FIELDS: 'team:updateTaskFields',
TEAM_UPDATE_TASK_OWNER: 'team:updateTaskOwner',
TEAM_PROCESS_SEND: 'team:processSend',
TEAM_PROCESS_ALIVE: 'team:processAlive',
TEAM_ALIVE_LIST: 'team:aliveList',
TEAM_STOP: 'team:stop',
TEAM_GET_MEMBER_LOGS: 'team:getMemberLogs',
TEAM_GET_LOGS_FOR_TASK: 'team:getLogsForTask',
TEAM_GET_MEMBER_STATS: 'team:getMemberStats',
TEAM_UPDATE_CONFIG: 'team:updateConfig',
TEAM_START_TASK: 'team:startTask',
TEAM_GET_ALL_TASKS: 'team:getAllTasks',
TEAM_ADD_TASK_COMMENT: 'team:addTaskComment',
TEAM_ADD_MEMBER: 'team:addMember',
TEAM_REPLACE_MEMBERS: 'team:replaceMembers',
TEAM_REMOVE_MEMBER: 'team:removeMember',
TEAM_UPDATE_MEMBER_ROLE: 'team:updateMemberRole',
TEAM_GET_PROJECT_BRANCH: 'team:getProjectBranch',
TEAM_GET_ATTACHMENTS: 'team:getAttachments',
TEAM_KILL_PROCESS: 'team:killProcess',
TEAM_LEAD_ACTIVITY: 'team:leadActivity',
TEAM_LEAD_CONTEXT: 'team:leadContext',
TEAM_SOFT_DELETE_TASK: 'team:softDeleteTask',
TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks',
TEAM_SET_TASK_CLARIFICATION: 'team:setTaskClarification',
TEAM_SHOW_MESSAGE_NOTIFICATION: 'team:showMessageNotification',
TEAM_ADD_TASK_RELATIONSHIP: 'team:addTaskRelationship',
TEAM_REMOVE_TASK_RELATIONSHIP: 'team:removeTaskRelationship',
TEAM_RESTORE: 'team:restoreTeam',
TEAM_PERMANENTLY_DELETE: 'team:permanentlyDeleteTeam',
TEAM_RESTORE_TASK: 'team:restoreTask',
TEAM_SAVE_TASK_ATTACHMENT: 'team:saveTaskAttachment',
TEAM_GET_TASK_ATTACHMENT: 'team:getTaskAttachment',
TEAM_DELETE_TASK_ATTACHMENT: 'team:deleteTaskAttachment',
}));
import { import {
TEAM_ALIVE_LIST, TEAM_ALIVE_LIST,