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:
parent
f1d08e6d33
commit
8ada8dfcf5
19 changed files with 531 additions and 346 deletions
|
|
@ -134,6 +134,7 @@
|
|||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unified": "^11.0.5",
|
||||
"yet-another-react-lightbox": "^3.29.1",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -230,6 +230,9 @@ importers:
|
|||
unified:
|
||||
specifier: ^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:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.7(@types/react@18.3.27)(react@18.3.1)
|
||||
|
|
@ -6164,6 +6167,20 @@ packages:
|
|||
yauzl@2.10.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -13183,6 +13200,14 @@ snapshots:
|
|||
buffer-crc32: 0.2.13
|
||||
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: {}
|
||||
|
||||
zip-stream@4.1.1:
|
||||
|
|
|
|||
|
|
@ -1516,7 +1516,11 @@ export class TeamAgentToolsInstaller {
|
|||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
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';
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ const SECTIONS: readonly { id: string; label: string; icon: LucideIcon }[] = [
|
|||
{ id: 'team', label: 'Team', icon: Users },
|
||||
{ id: 'sessions', label: 'Sessions', icon: History },
|
||||
{ id: 'kanban', label: 'Kanban', icon: Columns3 },
|
||||
{ id: 'claude-logs', label: 'Claude Logs', icon: Terminal },
|
||||
{ id: 'messages', label: 'Messages', icon: MessageSquare },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -157,8 +157,17 @@ export const GlobalTaskList = ({
|
|||
variant: 'danger',
|
||||
});
|
||||
if (confirmed) {
|
||||
await softDeleteTask(teamName, taskId);
|
||||
await fetchAllTasks();
|
||||
try {
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ export const ProvisioningProgressBlock = ({
|
|||
{message}
|
||||
</p>
|
||||
) : 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) => {
|
||||
const isDone = currentStepIndex >= 0 && index < currentStepIndex;
|
||||
const isCurrent = currentStepIndex >= 0 && index === currentStepIndex;
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
clearProvisioningError,
|
||||
isTeamProvisioning,
|
||||
leadActivityByTeam,
|
||||
leadContextByTeam,
|
||||
refreshTeamData,
|
||||
kanbanFilterQuery,
|
||||
clearKanbanFilter,
|
||||
|
|
@ -262,6 +263,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
|
||||
),
|
||||
leadActivityByTeam: s.leadActivityByTeam,
|
||||
leadContextByTeam: s.leadContextByTeam,
|
||||
refreshTeamData: s.refreshTeamData,
|
||||
kanbanFilterQuery: s.kanbanFilterQuery,
|
||||
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);
|
||||
return total > 0 ? total : undefined;
|
||||
}, [leadSessionDetail?.processes]);
|
||||
const leadContextPercent = leadContextByTeam[teamName]?.percent;
|
||||
|
||||
const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => {
|
||||
if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) {
|
||||
|
|
@ -431,6 +434,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
|
||||
}, [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(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
|
|
@ -715,7 +727,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
variant: 'danger',
|
||||
});
|
||||
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
|
||||
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}
|
||||
>
|
||||
{/* Sticky Context button (same interaction as Session view) */}
|
||||
{/* Sticky context button in top-right while scrolling */}
|
||||
{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
|
||||
onClick={() => {
|
||||
const next = !isContextPanelVisible;
|
||||
|
|
@ -879,7 +895,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
: leadSessionId
|
||||
}
|
||||
>
|
||||
{leadSessionLoaded ? `Context (${allContextInjections.length})` : 'Context'}
|
||||
{visibleContextPercentOfTotal !== null
|
||||
? `${visibleContextPercentOfTotal.toFixed(1)}% of total`
|
||||
: typeof leadContextPercent === 'number'
|
||||
? `${Math.round(leadContextPercent)}%`
|
||||
: 'Context'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1192,18 +1212,34 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
</div>
|
||||
}
|
||||
onRequestReview={(taskId) => {
|
||||
void requestReview(teamName, taskId);
|
||||
void (async () => {
|
||||
try {
|
||||
await requestReview(teamName, taskId);
|
||||
} catch {
|
||||
// error via store
|
||||
}
|
||||
})();
|
||||
}}
|
||||
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) => {
|
||||
setRequestChangesTaskId(taskId);
|
||||
}}
|
||||
onMoveBackToDone={(taskId) => {
|
||||
void (async () => {
|
||||
await updateKanban(teamName, taskId, { op: 'remove' });
|
||||
await updateTaskStatus(teamName, taskId, 'completed');
|
||||
try {
|
||||
await updateKanban(teamName, taskId, { op: 'remove' });
|
||||
await updateTaskStatus(teamName, taskId, 'completed');
|
||||
} catch {
|
||||
// error via store
|
||||
}
|
||||
})();
|
||||
}}
|
||||
onStartTask={(taskId) => {
|
||||
|
|
@ -1237,7 +1273,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
})();
|
||||
}}
|
||||
onCompleteTask={(taskId) => {
|
||||
void updateTaskStatus(teamName, taskId, 'completed');
|
||||
void (async () => {
|
||||
try {
|
||||
await updateTaskStatus(teamName, taskId, 'completed');
|
||||
} catch {
|
||||
// error via store
|
||||
}
|
||||
})();
|
||||
}}
|
||||
onCancelTask={(taskId) => {
|
||||
void (async () => {
|
||||
|
|
|
|||
|
|
@ -382,7 +382,11 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
variant: 'danger',
|
||||
});
|
||||
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(
|
||||
(teamName: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
void restoreTeam(teamName);
|
||||
void (async () => {
|
||||
try {
|
||||
await restoreTeam(teamName);
|
||||
} catch {
|
||||
// error via store
|
||||
}
|
||||
})();
|
||||
},
|
||||
[restoreTeam]
|
||||
);
|
||||
|
|
@ -409,7 +419,11 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
variant: 'danger',
|
||||
});
|
||||
if (confirmed) {
|
||||
void permanentlyDeleteTeam(teamName);
|
||||
try {
|
||||
await permanentlyDeleteTeam(teamName);
|
||||
} catch {
|
||||
// error via store
|
||||
}
|
||||
}
|
||||
})();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -87,10 +87,10 @@ export const AttachmentDisplay = ({
|
|||
</div>
|
||||
{lightboxIndex !== null && items[lightboxIndex] ? (
|
||||
<ImageLightbox
|
||||
src={items[lightboxIndex].dataUrl}
|
||||
alt={items[lightboxIndex].meta.filename}
|
||||
open
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
slides={items.map((item) => ({ src: item.dataUrl, alt: item.meta.filename }))}
|
||||
index={lightboxIndex}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface ImageLightboxProps {
|
||||
open: boolean;
|
||||
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 = ({
|
||||
src,
|
||||
alt = 'Image',
|
||||
open,
|
||||
onClose,
|
||||
slides: slidesProp,
|
||||
index = 0,
|
||||
src,
|
||||
alt,
|
||||
enableZoom = true,
|
||||
enableFullscreen = true,
|
||||
showCounter,
|
||||
}: ImageLightboxProps): React.JSX.Element | null => {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
const slides = useMemo<Slide[]>(() => {
|
||||
if (slidesProp && slidesProp.length > 0) {
|
||||
return slidesProp.map((s) => ({ src: s.src, alt: s.alt, title: s.title }));
|
||||
}
|
||||
if (src) {
|
||||
return [{ src, alt }];
|
||||
}
|
||||
return [];
|
||||
}, [slidesProp, src, alt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, handleKeyDown]);
|
||||
const plugins = useMemo<Plugin[]>(() => {
|
||||
const list: Plugin[] = [];
|
||||
if (enableZoom) list.push(Zoom);
|
||||
if (enableFullscreen) list.push(Fullscreen);
|
||||
// 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 (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm duration-150 animate-in fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={alt}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 border-0 bg-transparent p-0"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
<Lightbox
|
||||
open={open}
|
||||
close={onClose}
|
||||
slides={slides}
|
||||
index={index}
|
||||
plugins={plugins}
|
||||
carousel={{ finite: slides.length <= 1 }}
|
||||
animation={{ fade: 200 }}
|
||||
zoom={{
|
||||
maxZoomPixelRatio: 5,
|
||||
scrollToZoom: true,
|
||||
}}
|
||||
styles={{
|
||||
container: { backgroundColor: 'rgba(0, 0, 0, 0.85)', backdropFilter: 'blur(8px)' },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,10 +14,9 @@ import {
|
|||
} from '@renderer/components/ui/dialog';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
|
||||
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 { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useAttachments } from '@renderer/hooks/useAttachments';
|
||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
|
|
@ -28,7 +27,7 @@ import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
|||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
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';
|
||||
|
||||
|
|
@ -82,15 +81,6 @@ export const SendMessageDialog = ({
|
|||
onClose,
|
||||
}: SendMessageDialogProps): React.JSX.Element => {
|
||||
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 [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
|
||||
const [quoteExpanded, setQuoteExpanded] = useState(false);
|
||||
|
|
@ -286,43 +276,12 @@ export const SendMessageDialog = ({
|
|||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="smd-recipient">Recipient</Label>
|
||||
<Combobox
|
||||
value={member}
|
||||
onValueChange={setMember}
|
||||
<MemberSelect
|
||||
members={members}
|
||||
value={member || null}
|
||||
onChange={(v) => setMember(v ?? '')}
|
||||
placeholder="Select member..."
|
||||
searchPlaceholder="Search members..."
|
||||
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}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
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 type { TaskAttachmentMeta } from '@shared/types';
|
||||
|
|
@ -30,14 +31,21 @@ export const TaskAttachments = ({
|
|||
const [uploading, setUploading] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<{
|
||||
id: string;
|
||||
mimeType: string;
|
||||
dataUrl: string | null;
|
||||
loading: boolean;
|
||||
} | null>(null);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const [thumbCache, setThumbCache] = useState<Map<string, string>>(new Map());
|
||||
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(
|
||||
async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
|
|
@ -79,16 +87,13 @@ export const TaskAttachments = ({
|
|||
setDeletingId(attachmentId);
|
||||
try {
|
||||
await deleteTaskAttachment(teamName, taskId, attachmentId, mimeType);
|
||||
if (previewAttachment?.id === attachmentId) {
|
||||
setPreviewAttachment(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
},
|
||||
[teamName, taskId, deleteTaskAttachment, previewAttachment]
|
||||
[teamName, taskId, deleteTaskAttachment]
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
|
|
@ -119,35 +124,15 @@ export const TaskAttachments = ({
|
|||
);
|
||||
|
||||
const handlePreview = useCallback(
|
||||
async (att: TaskAttachmentMeta) => {
|
||||
(att: TaskAttachmentMeta) => {
|
||||
if (!isImageMimeType(att.mimeType)) {
|
||||
void handleDownload(att);
|
||||
return;
|
||||
}
|
||||
if (previewAttachment?.id === att.id && previewAttachment.dataUrl) {
|
||||
setPreviewAttachment(null);
|
||||
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');
|
||||
}
|
||||
const idx = imageAttachments.findIndex((a) => a.id === att.id);
|
||||
if (idx >= 0) setLightboxIndex(idx);
|
||||
},
|
||||
[teamName, taskId, getTaskAttachmentData, previewAttachment, handleDownload]
|
||||
[imageAttachments, handleDownload]
|
||||
);
|
||||
|
||||
// Handle paste events for quick image attachment
|
||||
|
|
@ -212,38 +197,28 @@ export const TaskAttachments = ({
|
|||
teamName={teamName}
|
||||
taskId={taskId}
|
||||
isDeleting={deletingId === att.id}
|
||||
isPreviewActive={previewAttachment?.id === att.id}
|
||||
onPreview={() => void handlePreview(att)}
|
||||
onDelete={() => void handleDelete(att.id, att.mimeType)}
|
||||
onDataLoaded={handleThumbLoaded}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Preview panel */}
|
||||
{previewAttachment ? (
|
||||
<div className="relative rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
||||
<button
|
||||
type="button"
|
||||
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)]"
|
||||
onClick={() => setPreviewAttachment(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{previewAttachment.loading ? (
|
||||
<div className="flex items-center gap-2 py-4 text-xs text-[var(--color-text-muted)]">
|
||||
<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}
|
||||
{/* Image lightbox */}
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
open
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
slides={imageAttachments
|
||||
.map((att) => {
|
||||
const dataUrl = thumbCache.get(att.id);
|
||||
return dataUrl ? { src: dataUrl, alt: att.filename } : null;
|
||||
})
|
||||
.filter(Boolean) as { src: string; alt: string }[]}
|
||||
index={lightboxIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drop zone indicator */}
|
||||
{dragOver ? (
|
||||
|
|
@ -289,9 +264,9 @@ interface AttachmentThumbnailProps {
|
|||
teamName: string;
|
||||
taskId: string;
|
||||
isDeleting: boolean;
|
||||
isPreviewActive: boolean;
|
||||
onPreview: () => void;
|
||||
onDelete: () => void;
|
||||
onDataLoaded?: (attachmentId: string, dataUrl: string) => void;
|
||||
}
|
||||
|
||||
const AttachmentThumbnail = ({
|
||||
|
|
@ -299,9 +274,9 @@ const AttachmentThumbnail = ({
|
|||
teamName,
|
||||
taskId,
|
||||
isDeleting,
|
||||
isPreviewActive,
|
||||
onPreview,
|
||||
onDelete,
|
||||
onDataLoaded,
|
||||
}: AttachmentThumbnailProps): React.JSX.Element => {
|
||||
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
|
||||
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||||
|
|
@ -318,7 +293,9 @@ const AttachmentThumbnail = ({
|
|||
attachment.mimeType
|
||||
);
|
||||
if (!cancelled && base64) {
|
||||
setThumbUrl(`data:${attachment.mimeType};base64,${base64}`);
|
||||
const dataUrl = `data:${attachment.mimeType};base64,${base64}`;
|
||||
setThumbUrl(dataUrl);
|
||||
onDataLoaded?.(attachment.id, dataUrl);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
|
|
@ -327,7 +304,7 @@ const AttachmentThumbnail = ({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]);
|
||||
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData, onDataLoaded]);
|
||||
|
||||
const sizeLabel =
|
||||
attachment.size < 1024
|
||||
|
|
@ -338,11 +315,7 @@ const AttachmentThumbnail = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex size-20 cursor-pointer items-center justify-center overflow-hidden rounded border transition-colors ${
|
||||
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)]`}
|
||||
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)]`}
|
||||
onClick={onPreview}
|
||||
>
|
||||
{isImageMimeType(attachment.mimeType) ? (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
|
||||
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
|
|
@ -305,22 +306,14 @@ export const TaskCommentsSection = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Full-size image preview overlay */}
|
||||
{/* Image lightbox */}
|
||||
{previewImageUrl ? (
|
||||
<div className="relative mb-3 rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
||||
<button
|
||||
type="button"
|
||||
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)]"
|
||||
onClick={() => setPreviewImageUrl(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt="Attachment preview"
|
||||
className="max-h-[400px] max-w-full rounded object-contain"
|
||||
/>
|
||||
</div>
|
||||
<ImageLightbox
|
||||
open
|
||||
onClose={() => setPreviewImageUrl(null)}
|
||||
src={previewImageUrl}
|
||||
alt="Attachment preview"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!hideInput && (
|
||||
|
|
@ -416,6 +409,7 @@ const CommentAttachmentThumbnail = ({
|
|||
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
|
||||
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -441,58 +435,76 @@ const CommentAttachmentThumbnail = ({
|
|||
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]);
|
||||
|
||||
return (
|
||||
<div
|
||||
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)]"
|
||||
onClick={() => {
|
||||
if (isImageMimeType(attachment.mimeType)) {
|
||||
if (thumbUrl) onPreview(thumbUrl);
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const base64 = await getTaskAttachmentData(
|
||||
teamName,
|
||||
taskId,
|
||||
attachment.id,
|
||||
attachment.mimeType
|
||||
);
|
||||
if (!base64) return;
|
||||
const mime =
|
||||
attachment.mimeType && typeof attachment.mimeType === 'string'
|
||||
? attachment.mimeType
|
||||
: 'application/octet-stream';
|
||||
const dataUrl = `data:${mime};base64,${base64}`;
|
||||
const blob = await fetch(dataUrl).then((r) => r.blob());
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = attachment.filename || 'attachment';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{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)]" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`group relative flex size-14 cursor-pointer items-center justify-center overflow-hidden rounded border bg-[var(--color-surface)] transition-colors ${
|
||||
downloadError
|
||||
? 'border-red-500/60'
|
||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-emphasis)]'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (isImageMimeType(attachment.mimeType)) {
|
||||
if (thumbUrl) onPreview(thumbUrl);
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
setDownloading(true);
|
||||
setDownloadError(null);
|
||||
try {
|
||||
const base64 = await getTaskAttachmentData(
|
||||
teamName,
|
||||
taskId,
|
||||
attachment.id,
|
||||
attachment.mimeType
|
||||
);
|
||||
if (!base64) return;
|
||||
const mime =
|
||||
attachment.mimeType && typeof attachment.mimeType === 'string'
|
||||
? attachment.mimeType
|
||||
: 'application/octet-stream';
|
||||
const dataUrl = `data:${mime};base64,${base64}`;
|
||||
const blob = await fetch(dataUrl).then((r) => r.blob());
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = attachment.filename || 'attachment';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
setDownloadError(err instanceof Error ? err.message : 'Download failed');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{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">
|
||||
{attachment.filename}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
||||
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -16,19 +17,10 @@ import {
|
|||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import {
|
||||
buildMemberColorMap,
|
||||
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 min-w-0 items-center gap-2">
|
||||
{canReassign ? (
|
||||
<Select
|
||||
value={currentTask.owner ?? '__unassigned__'}
|
||||
onValueChange={(v) => {
|
||||
onOwnerChange(currentTask.id, v === '__unassigned__' ? null : v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[140px] text-xs">
|
||||
<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>
|
||||
<MemberSelect
|
||||
members={members}
|
||||
value={currentTask.owner ?? null}
|
||||
onChange={(v) => onOwnerChange(currentTask.id, v)}
|
||||
allowUnassigned
|
||||
size="sm"
|
||||
className="min-w-[160px]"
|
||||
/>
|
||||
) : currentTask.owner ? (
|
||||
<MemberBadge
|
||||
name={currentTask.owner}
|
||||
|
|
|
|||
|
|
@ -126,10 +126,10 @@ export const EditorImagePreview = ({
|
|||
</div>
|
||||
|
||||
<ImageLightbox
|
||||
src={dataUrl}
|
||||
alt={fileName}
|
||||
open={lightboxOpen}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
src={dataUrl}
|
||||
alt={fileName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -270,9 +270,7 @@ export const KanbanTaskCard = ({
|
|||
<div className="flex items-center gap-1">
|
||||
{task.owner ? (
|
||||
<MemberBadge name={task.owner} color={colorMap.get(task.owner)} />
|
||||
) : (
|
||||
<span className="text-xs text-[var(--color-text-muted)]">Unassigned</span>
|
||||
)}
|
||||
) : null}
|
||||
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
|
||||
</div>
|
||||
{task.needsClarification ? (
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
|
|||
<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-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)]">
|
||||
{task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY
|
||||
? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label
|
||||
|
|
|
|||
203
src/renderer/components/ui/MemberSelect.tsx
Normal file
203
src/renderer/components/ui/MemberSelect.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -8,58 +8,11 @@ vi.mock('electron', () => ({
|
|||
BrowserWindow: { getAllWindows: vi.fn(() => []) },
|
||||
}));
|
||||
|
||||
vi.mock('@preload/constants/ipcChannels', () => ({
|
||||
TEAM_LIST: 'team:list',
|
||||
TEAM_GET_DATA: 'team:getData',
|
||||
TEAM_DELETE_TEAM: 'team:deleteTeam',
|
||||
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',
|
||||
}));
|
||||
// Keep this mock resilient to new exports (avoid drift).
|
||||
vi.mock('@preload/constants/ipcChannels', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@preload/constants/ipcChannels')>();
|
||||
return { ...actual };
|
||||
});
|
||||
|
||||
import {
|
||||
TEAM_ALIVE_LIST,
|
||||
|
|
|
|||
Loading…
Reference in a new issue